Country packs
Everything that differs between countries lives in a country pack: tax presets, national rules and profiles, the automatic format choice, the values a PDF must show, legal texts, numbering and retention rules, dunning law, the cancellation and correction policy, payment QR codes, address formatting and letter layouts. The core stays country-neutral.
A country pack implements Quittio\Core\Countries\CountryPack, usually by extending Quittio\Core\Countries\AbstractCountryPack, which supplies neutral defaults for every hook. A pack may only add or tighten rules; it never relaxes EN 16931.
The first pack is Germany (quittio/country-de, below). The legal wording and references of the packs are proposals that must be confirmed by a tax adviser (see the legal review in the README).
How a pack is selected
DefaultCountryResolver picks the packs for every run (render, check, prepare, documents, cancellations and the validation of incoming files):
- Leading pack (seller side). The first of these that is set: the country passed to the run (
->country('AT')on the pending invoice,RenderOptions::$country), the seller’s country code (BT-40), the configured default country (quittio.defaults.country, core config keycountry), otherwiseXX. The registry returns the installed pack for that code, or aCountryGenericpack. - Buyer pack. If the buyer’s country code (BT-55) is set and differs from the leading pack’s code, the installed pack of the buyer’s country is added (no generic pack is created for the buyer).
$quittio->make($invoice)->country('DE')->render(); // force the leading pack
What each side contributes:
| Hook | Leading pack | Buyer pack (cross-border only) |
|---|---|---|
rules() |
applied | – |
buyerCountryRules() |
– | applied |
formatRules() |
domestic invoices | cross-border business invoices (see automatic format choice) |
mandatoryOutput() |
merged with the base fields of the mandatory field guard | – |
defaults() (locale, paper, letter layout), letterLayouts(), addressFormatter(), paymentQr() |
locale fallback and PDF | – |
correctionPolicy() |
used for cancellations (Quittio::cancellation()) |
– |
translationPath() |
registered | registered |
“Domestic” means that seller and buyer lie in the same VAT territory (see VatTerritory) or the buyer has no country code. A buyer in a territory excluded from the seller’s VAT territory (e.g. Büsingen for Germany) is cross-border, but without an own buyer pack the format rules of the leading pack still apply to it.
Without a pack for the seller’s country, CountryGeneric applies: EN 16931 rules only, international formatting, neutral defaults. Validation then reports the warning QUITTIO-GENERIC-COUNTRY (L3, BT-40): “No country pack is installed for the seller country :value. Only EN 16931 rules are checked; national mandatory information is not validated.” ResolvedCountries::isGeneric() and CountryGeneric::isGeneric() tell this case apart.
With the Managed layer, the issuer’s country selects the pack for numbering and retention; the leading pack of an issued invoice is stored with it (country_pack) and used again for re-rendering and corrections (see Managed).
Registering packs
Laravel
Packs are found in three places; all of them are instantiated through the container, so constructors may use dependency injection:
-
Composer metadata. Every installed package and the root
composer.jsonof your application may declare packs.quittio/country-dedeclares itself this way, socomposer require quittio/country-de(or the meta packagequittio/quittio) is all you need. Classes that do not exist are skipped."extra": { "quittio": { "country-packs": ["Acme\\CountryAT\\CountryAT"] } } -
Configuration.
quittio.countrieslists further pack classes; keys are ignored (see configuration).'countries' => [ 'AT' => \Acme\CountryAT\CountryAT::class, ], -
At runtime.
Quittio::registerCountry(new CountryAT)(facadeQuittio\Laravel\Facades\Quittio), e.g. in a service provider. Packs registered this way win over packs with the same code from the other two sources.
Packs are keyed by code(): of two packs with the same code, the one registered last wins. Discovered packs come first, then the configured classes, then packs registered at runtime, so a subclass of CountryDE in quittio.countries replaces the discovered CountryDE.
php artisan quittio:countries lists the installed packs with code, class, profiles, number of rules and format rules and the defaults (currency, locale, profile). Without any pack it prints a warning (see commands).
Without Laravel
use Quittio\Core\Quittio;
use Quittio\Country\DE\CountryDE;
$quittio = Quittio::create(['country' => 'DE'], [new CountryDE]);
$quittio->registerCountry(new CountryAT); // further packs at any time
Registering a pack does three things: it adds the pack to the CountryRegistry under its upper-case code, registers the pack’s profiles() in the profile registry (so that formats, ->profile('xrechnung') and the reader know them) and registers translationPath() as the translation namespace quittio-{code} (lower case, e.g. quittio-de).
$quittio->countries() returns the registry: has($code), get($code) (installed pack or null), getOrGeneric($code) and all() (array keyed by code). Codes are case-insensitive.
The CountryPack contract
| Method | Returns | Default in AbstractCountryPack |
Used by |
|---|---|---|---|
code() |
ISO 3166-1 alpha-2 code, e.g. DE |
– (abstract) | registry key, translation namespace quittio-{code} |
defaults() |
CountryDefaults |
new CountryDefaults |
locale fallback, paper size, default letter layout |
taxPresets() |
TaxPresetCollection |
empty | lookup of presets by key; presets are never applied automatically |
rules() |
list<Rule> |
[] |
validation, when the pack leads |
buyerCountryRules() |
list<Rule> |
[] |
validation, when the pack is the buyer’s pack of a cross-border invoice |
profiles() |
list<Profile> |
[] |
profile registry (national CIUS such as XRechnung) |
formatRules() |
list<FormatRule> |
[] |
automatic format choice |
mandatoryOutput(InvoiceData $invoice) |
MandatoryFieldList |
empty | mandatory field guard (leading pack only) |
legalTexts() |
LegalTextCatalog |
empty | the pack’s own helpers and your code; Quittio itself does not read it |
numberingRules() |
NumberingRules |
new NumberingRules |
Managed numbering |
retention() |
RetentionRule |
new RetentionRule(10) |
Managed retention |
dunningLaw() |
DunningLaw |
new DunningLaw |
informational; not read by Quittio in this release |
correctionPolicy() |
CorrectionPolicy |
new CorrectionPolicy |
Quittio::cancellation(), Managed corrections |
paymentQr() |
?PaymentQrGenerator |
null |
payment QR code in mode auto (after a generator registered with withPaymentQrGenerator()); null falls back to CountryDefaults::$paymentQr (EPC GiroCode for girocode, no code for none) |
addressFormatter() |
AddressFormatter |
InternationalAddressFormatter |
address lines of the view model |
letterLayouts() |
array<string, LetterLayout> |
['free' => LetterLayout::free()] |
position of the address field and marks |
exporters() |
list<Exporter> |
[] |
declared for accounting exports of the Managed layer (Exporter::key()); not read in this release |
readers() |
list<FormatReader> |
[] |
readers for national formats; read(), readFile() and validateFile() try them after the built-in CII and UBL readers, for every registered pack |
translationPath() |
?string |
null |
directory with {locale}/{group}.php files, registered as namespace quittio-{code} |
CountryDefaults
new CountryDefaults(currency: 'EUR', timezone: 'UTC', locale: 'en', paper: 'A4', letterLayout: 'free', profile: 'en16931', requiredLocales: [], paymentQr: 'girocode')
| Property | Default | Effect |
|---|---|---|
locale |
en |
Last fallback for the render and validation locale: run option, invoice locale (->locale()), quittio.defaults.locale, then this value. |
paper |
A4 |
Paper size unless the presentation or pdf.paper sets one. |
letterLayout |
free |
Key of letterLayouts() used when the presentation sets none (pdf.layout). |
currency, profile |
EUR, en16931 |
Shown by quittio:countries; not applied to invoices. |
timezone, requiredLocales |
UTC, [] |
Informational metadata for applications; not applied by the core. Set the time zone of dates with ->timezone() on the builder. |
paymentQr |
girocode |
QR code of the mode auto when the pack has no paymentQr() generator: girocode = EPC GiroCode, none = no code. A generator registered with withPaymentQrGenerator() comes first. |
CorrectionPolicy
Default handling of cancellations and corrections (see Managed: cancellations and corrections).
| Member | Meaning |
|---|---|
CANCELLATION_CREDIT_NOTE = credit_note |
cancel with type code 381 and positive amounts |
CANCELLATION_NEGATIVE_INVOICE = negative_invoice |
cancel with type code 380 and negative amounts |
CORRECTION_CANCEL_AND_REISSUE = cancel_and_reissue |
correct by cancelling and issuing a new invoice |
CORRECTION_CORRECTED_INVOICE = corrected_invoice |
correct with a corrected invoice (type code 384) |
__construct(string $cancellationStrategy = 'credit_note', string $correctionStrategy = 'cancel_and_reissue', array $titles = []) |
$titles: document kind => [locale => title] |
title(string $kind, string $locale = 'en'): ?string |
title in the locale, else English, else null |
cancellationTitle(bool $selfBilled, string $locale = 'en'): ?string |
cancellation_self_billed for self-billed originals if defined, otherwise cancellation |
Kinds read by Quittio: cancellation and cancellation_self_billed (title of Quittio::cancellation()), correction (title of a Managed corrected invoice), self_billed (PDF/HTML title and file name of self-billed invoices, after a title set in the presentation). The sign strategy of a cancellation comes from the call, then quittio.corrections.cancellation_strategy, then the pack; the correction strategy from quittio-managed.correction.strategy, then the pack.
DunningLaw
Statutory late payment values; amounts and percentage points are decimal strings.
| Property | Default | Meaning |
|---|---|---|
b2bInterestPoints |
8.00 |
points above the reference rate for business debtors |
b2cInterestPoints |
0.00 |
points above the reference rate for consumers |
b2bFlatRate |
40.00 |
flat rate for business debtors (null = none) |
dayCount |
365 |
day count basis |
consumerDefaultAfterDays |
null |
days after which a consumer is in default |
referenceRateName |
null |
name of the reference rate |
Quittio’s documents do not read these values; country packs use them in their own interest calculators (Germany: DefaultInterest).
NumberingRules
Used by Managed numbering (see Managed: numbering).
| Property | Default | Meaning |
|---|---|---|
defaultPattern |
INV-{Y}-{seq:4} |
pattern when neither the issuer nor the configuration sets one |
defaultReset |
yearly |
reset when none is configured |
allowedResets |
['never', 'yearly', 'monthly'] |
other resets are rejected with a ConfigurationException |
gapless |
true |
declared; not read in this release (see gapless numbers) |
numberConsumedOnRejection |
true |
declared; not read in this release |
RetentionRule
new RetentionRule(int $years, bool $fromEndOfCalendarYear = true). until(Date $issueDate): Date returns the last day of the retention period: 31 December of the issue year plus $years, or, with fromEndOfCalendarYear: false, the issue date plus $years. The Managed layer stores this date with every issued invoice; quittio-managed.retention.years may only lengthen it.
LegalTextCatalog
new LegalTextCatalog(array $texts = [], string $fallbackLocale = 'en') with $texts as key => [locale => text].
has(string $key): boolget(string $key, ?string $locale = null, array $replace = []): ?string: the text in$locale, else in the fallback locale, else the first variant; null for unknown keys.:nameplaceholders are replaced from$replace.
Mandatory output
mandatoryOutput() names values the rendered PDF must show for one invoice, in addition to the base fields every PDF needs. After rendering, the mandatory field guard checks that the template printed them (see templates: mandatory field guard).
new MandatoryField(string $accessKey, string $description, ?string $legalBasis = null). The access key is a view model path:
|separates alternatives,&combines paths that must all be printed;[*]stands for every index, e.g.vatRows[*].amount;- a path counts only where the view model has a value: null values and empty lists are not required, and a field whose alternatives all have no value is skipped.
MandatoryFieldList::merge() combines two lists; for equal access keys the later field wins. MandatoryFieldGuard::notePath($invoice, $matches) returns the view model path of the first note matching a callback (legalNotes[i] for regulatory, legal and tax notes, notes[i].content|notes[i].html otherwise), so a pack can require a specific note.
Letter layouts
letterLayouts() returns Quittio\Core\View\LetterLayout objects by name; CountryDefaults::$letterLayout names the default, the presentation property letterLayout (Laravel: pdf.layout) another one. free is always available; an unknown name throws a ConfigurationException. Values are millimetres from the top left corner of the first page:
| Property | Default | Meaning |
|---|---|---|
name |
– | layout key |
addressTop, addressLeft |
45.0, 20.0 | position of the address field |
addressWidth, addressHeight |
85.0, 45.0 | size of the address field |
senderLineHeight |
5.0 | height of the sender line above the recipient |
addressZoneTop |
17.7 | distance of the recipient address from the top of the field |
foldMarks |
[] |
distances of the fold marks from the top edge |
punchMark |
null | distance of the punch mark |
letterheadHeight(float $pageMarginTop) returns the space above the address field (addressTop minus the top page margin of 20 mm in the default templates). See templates: letter layouts for how the default templates use them.
VatTerritory
Quittio\Core\Countries\VatTerritory tells the EU VAT territory (Art. 5 to 7 of Directive 2006/112/EC) from a country code and a postcode. The format resolver, the German rules and the core plausibility checks use it; packs can use it in their own rules.
| Method / constant | Result |
|---|---|
EU_MEMBER_STATES |
the 27 member states (Greece is GR) |
NORTHERN_IRELAND |
XI |
vatCountry(string $country): string |
the country whose VAT territory a country belongs to: MC → FR, IM → GB, EL → GR |
isEuMemberState(string $country): bool |
member state after vatCountry() |
excludedTerritory(string $country, ?string $postcode): ?string |
name of an excluded territory the address lies in, or null |
isEuVatTerritory(string $country, ?string $postcode = null, bool $goods = true): bool |
address inside the EU VAT territory |
memberState(string $country, ?string $postcode = null, bool $goods = true): ?string |
member state of the address (XI for Northern Ireland and goods), null outside |
sameVatTerritory(string $country, ?string $postcode, string $otherCountry, ?string $otherPostcode): bool |
same VAT territory, e.g. FR and MC, but not DE and Büsingen |
isNorthernIreland(string $country, ?string $postcode = null): bool |
XI, or GB with a BT postcode |
memberStateOfVatId(string $vatId): ?string |
member state of a VAT identifier prefix (EL → GR, XI stays XI), null otherwise (also for the prefix GR) |
Northern Ireland belongs to the territory for goods only ($goods = false excludes it). Excluded territories recognised by postcode: Büsingen am Hochrhein (78266) and Heligoland (27498) in Germany; the Canary Islands (35xxx, 38xxx), Ceuta (51xxx) and Melilla (52xxx); Livigno (23041) and Campione d’Italia (22061); the Åland Islands (22xxx in FI); Mount Athos (63086); the French overseas territories (97xxx, 981xx to 989xx; Monaco 980xx stays France). Territories without a distinct postcode (e.g. the part of Cyprus not controlled by the government) cannot be detected.
Writing your own country pack
- Create a Composer package that requires
quittio/core. - Extend
AbstractCountryPackand override only what differs:code()is the one required method. - Add tax presets, rules, format rules, mandatory output, layouts and texts as needed.
- Ship translations in
resources/lang/{locale}/rules.phpand return the directory fromtranslationPath(). - Declare the pack in
composer.json(extra.quittio.country-packs) so that the Laravel integration finds it. - Test it with example invoices and, where they exist, the official validation artefacts of the country, and have the legal content reviewed by a local tax adviser.
A minimal pack (values for illustration only):
namespace Acme\CountryAT;
use Quittio\Core\Countries\AbstractCountryPack;
use Quittio\Core\Countries\CountryDefaults;
use Quittio\Core\Countries\MandatoryField;
use Quittio\Core\Countries\MandatoryFieldList;
use Quittio\Core\Countries\RetentionRule;
use Quittio\Core\Data\InvoiceData;
use Quittio\Core\Enums\VatCategory;
use Quittio\Core\Tax\CustomTaxPreset;
use Quittio\Core\Tax\TaxPresetCollection;
use Quittio\Core\Validation\Rules\CallbackRule;
use Quittio\Core\Validation\Rules\Inspect;
use Quittio\Core\Validation\Rules\RuleReporter;
final class CountryAT extends AbstractCountryPack
{
public function code(): string
{
return 'AT';
}
public function defaults(): CountryDefaults
{
return new CountryDefaults(currency: 'EUR', timezone: 'Europe/Vienna', locale: 'de');
}
public function taxPresets(): TaxPresetCollection
{
return new TaxPresetCollection([
CustomTaxPreset::make('at.standard_20', VatCategory::StandardRate, '20'),
]);
}
public function rules(): array
{
return [
CallbackRule::l3('ACME-AT-01', 'The seller VAT identifier (BT-31) must be stated.', function (InvoiceData $i, RuleReporter $report): void {
if (! Inspect::filled($i->seller->vatId)) {
$report->error('BT-31', 'seller.vatId');
}
}, 'quittio-at'),
];
}
public function formatRules(): array
{
return [new ConsumerPdfRule];
}
public function mandatoryOutput(InvoiceData $invoice): MandatoryFieldList
{
return new MandatoryFieldList([new MandatoryField('seller.vatId', 'VAT identifier of the seller')]);
}
public function retention(): RetentionRule
{
return new RetentionRule(7);
}
public function translationPath(): string
{
return dirname(__DIR__).'/resources/lang';
}
}
use Quittio\Core\Data\InvoiceData;
use Quittio\Core\Formats\FormatChoice;
use Quittio\Core\Formats\FormatRegistry;
use Quittio\Core\Formats\FormatRule;
final class ConsumerPdfRule implements FormatRule
{
public function resolve(InvoiceData $invoice): ?FormatChoice
{
return $invoice->buyer->isConsumer
? new FormatChoice(FormatRegistry::PDF, null, 'Invoice to a consumer.')
: null; // null: the next rule or the default choice decides
}
}
resources/lang/de/rules.php:
return [
'ACME-AT-01' => [
'message' => 'Die UID des Verkäufers (BT-31) muss angegeben sein.',
'hint' => 'Setzen Sie ->vatId() beim Verkäufer.',
],
];
Presets of your pack
A preset implements Quittio\Core\Tax\TaxPreset:
| Method | Returns |
|---|---|
key(): string |
stable key prefixed with the country code, e.g. at.standard_20 |
category(): VatCategory |
VAT category (BT-151) |
rate(): ?BigDecimal |
rate in percent, null for category O |
exemptionReasonCode(): VatExemptionReasonCode\|RawCode\|null |
VATEX code (BT-121) |
exemptionReason(?string $locale = null): ?string |
exemption reason text (BT-120), shown in XML and PDF |
CustomTaxPreset::make(string $key, VatCategory $category, BigDecimal|int|string|null $rate = null, VatExemptionReasonCode|RawCode|string|null $code = null, array|string|null $reason = null) builds one ad hoc; $reason is a text or locale => text (a plain string counts as English; without a locale the English text is used). For many presets a backed enum is convenient, as Quittio\Country\DE\Tax\VatPreset shows. TaxPresetCollection offers add(), has($key), get($key) (throws InvalidArgumentException for unknown keys), count() and iteration.
A preset is applied per line with ->vat($preset); the line stores category, rate, reason, code and the preset key (LineVat::$presetKey), so that rules can recognise it (see Building invoices).
Rules of your pack
A rule implements Quittio\Core\Validation\Rule (code(), level(), check(InvoiceData, ValidationContext): iterable<ValidationMessage>). CallbackRule::l3($code, $fallback, $check, $namespace) (business rules) and CallbackRule::l7(...) (plausibility) cover most cases. The closure receives the invoice and a RuleReporter:
error(?string $bt = null, ?string $path = null, array $replace = []),warning(...)andinfo(...)add a finding with business term and data path;context()returns theValidationContext:profile,countryPacks(leading pack first),locale,option($key)(e.g.option('syntax')with theSyntaxthat will be written),profileIs(...$keys),leadingCountry(),hasCountry($code).
Messages come from the catalog key {namespace}::rules.{code} with the entries message and hint, in the validation locale or English; without an entry the English $fallback is used. :name placeholders are replaced from $replace. Use the namespace quittio-{code} that registration gives your translationPath(). Rules of the pack run in every format, also for a plain PDF; profile specific rules belong to a profile class (see formats). See validation for levels and results.
Format rules of your pack
Quittio\Core\Formats\FormatRule::resolve(InvoiceData $invoice): ?FormatChoice returns a FormatChoice(string $format, ?string $profile = null, ?string $reason = null) or null. Rules run in order, the first non-null choice wins. The resolver consults the leading pack for domestic invoices and the buyer’s pack for cross-border invoices to businesses; an explicit format always wins. A choice of zugferd or cii is upgraded to EXTENDED when EXTENDED features are used.
Other hooks of your pack
paymentQr(): aQuittio\Core\Pdf\Qr\PaymentQrGenerator(key(),supports(InvoiceData),payload(InvoiceData, array $options = []): ?string) for the QR modeauto.addressFormatter(): aQuittio\Core\View\AddressFormatter(format(Address $address, array $nameLines = [], ?string $senderCountry = null, ?string $locale = null): list<string>).profiles(): national profiles, usually extendingQuittio\Core\Profiles\AbstractProfile; they are registered with the pack.
Germany (quittio/country-de)
Quittio\Country\DE\CountryDE (code DE, translation namespace quittio-de) covers the German VAT act (UStG, UStDV), XRechnung and German business letter conventions. It is discovered automatically in Laravel; without Laravel pass new CountryDE to Quittio::create(). The builder macros are registered as soon as Composer loads the package.
| Hook | Germany |
|---|---|
defaults() |
currency EUR, time zone Europe/Berlin, locale de, paper A4, letter layout din5008b, profile en16931, payment QR girocode |
profiles() |
XRechnung30, XRechnung30Extension |
taxPresets() |
every case of VatPreset |
rules() |
UStG rules DE-USTG-01 to DE-USTG-15 and the Leitweg-ID checks DE-LEITWEG-ID and DE-LEITWEG-ID-SUSPECT |
buyerCountryRules() |
none |
formatRules() |
GermanFormatRule |
mandatoryOutput() |
mandatory PDF output |
legalTexts() |
LegalTexts::catalog() |
numberingRules() |
pattern RE-{Y}-{seq:4}, reset yearly, resets never, yearly, monthly, gapless |
retention() |
8 years from the end of the calendar year |
dunningLaw() |
§ 288 BGB values |
correctionPolicy() |
credit note, cancel and reissue, German titles |
letterLayouts() |
din5008a, din5008b, free |
paymentQr(), addressFormatter() |
defaults: EPC GiroCode, international address format |
translationPath() |
resources/lang of the package |
errorClassification() |
BMF error classes (additional method) |
Tax presets
Quittio\Country\DE\Tax\VatPreset is a string-backed enum implementing TaxPreset. Apply it per line:
use Quittio\Country\DE\Tax\VatPreset;
->line(fn ($l) => $l->name('Webentwicklung')->quantity(24, UnitCode::Hour)->netPrice('95.00')->vat(VatPreset::Standard19))
->line(fn ($l) => $l->name('Heilbehandlung')->quantity(1, UnitCode::Piece)->netPrice('90.00')->vat(VatPreset::exempt('§ 4 Nr. 14 UStG', 'VATEX-EU-132-1C')))
| Case | Key | Category | Rate | Code (BT-121) | Use |
|---|---|---|---|---|---|
Standard19 |
de.standard_19 |
S |
19 | – | standard rate |
Reduced7 |
de.reduced_7 |
S |
7 | – | reduced rate |
ZeroRatedPv |
de.zero_rated_pv |
Z |
0 | – | 0 % rate for photovoltaic systems (section 12 (3) UStG); the rate itself is the statement, no exemption reason (BR-Z-10) |
SmallBusiness |
de.small_business |
E |
0 | – | small business exemption (section 19 (1) UStG), see small businesses |
ReverseChargeDomestic |
de.reverse_charge_domestic |
AE |
0 | VATEX-EU-AE |
domestic reverse charge (section 13b UStG) |
ReverseChargeEu |
de.reverse_charge_eu |
AE |
0 | VATEX-EU-AE |
service whose recipient in another member state owes the VAT; both VAT identifiers required (DE-USTG-11); issue by the 15th of the following month (DE-USTG-15) |
IntraCommunitySupply |
de.intra_community_supply |
K |
0 | VATEX-EU-IC |
VAT-exempt intra-Community supply (section 4 no. 1b with section 6a UStG), also of new vehicles (newVehicle()); not for small businesses except new vehicles (DE-USTG-14); issue by the 15th of the following month (DE-USTG-15) |
TriangularTransaction |
de.triangular_transaction |
AE |
0 | VATEX-EU-AE |
intra-community triangular transaction (section 25b UStG), wording of section 14a (7) UStG; both VAT identifiers required |
Export |
de.export |
G |
0 | VATEX-EU-G |
VAT-exempt export supply (section 4 no. 1a with section 6 UStG) |
NotTaxableInGermany |
de.not_taxable |
O |
none | VATEX-EU-O |
supply not taxable in Germany |
MarginSecondHand |
de.margin_second_hand |
E |
0 | VATEX-EU-F |
margin scheme, second-hand goods |
MarginArt |
de.margin_art |
E |
0 | VATEX-EU-I |
margin scheme, works of art |
MarginAntiques |
de.margin_antiques |
E |
0 | VATEX-EU-J |
margin scheme, collectors’ items and antiques |
TravelAgency |
de.travel_agency |
E |
0 | VATEX-EU-D |
special scheme for travel agents |
VatPreset::exempt(string $paragraph, $code = null) |
de.exempt |
E |
0 | $code (enum, raw code or string) |
any other exemption, e.g. § 4 Nr. 14 UStG |
Exemption reason texts (BT-120), from resources/lang/{de,en}/tax.php:
| Key | German | English |
|---|---|---|
de.small_business |
Steuerfreie Leistung nach § 19 Abs. 1 UStG (Steuerbefreiung für Kleinunternehmer). | VAT-exempt supply under section 19 (1) UStG (small business exemption). |
de.reverse_charge_domestic |
Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG) | Reverse charge, the recipient is liable for VAT (section 13b UStG) |
de.reverse_charge_eu |
Steuerschuldnerschaft des Leistungsempfängers | Reverse charge, the recipient is liable for VAT |
de.intra_community_supply |
Steuerfreie innergemeinschaftliche Lieferung (§ 4 Nr. 1b i. V. m. § 6a UStG) | VAT-exempt intra-Community supply (section 4 no. 1b in conjunction with section 6a UStG) |
de.triangular_transaction |
Innergemeinschaftliches Dreiecksgeschäft nach § 25b UStG; Steuerschuldnerschaft des letzten Abnehmers | Intra-Community triangular transaction (section 25b UStG); the last customer is liable for the VAT |
de.export |
Steuerfreie Ausfuhrlieferung (§ 4 Nr. 1a i. V. m. § 6 UStG) | VAT-exempt export supply (section 4 no. 1a in conjunction with section 6 UStG) |
de.not_taxable |
Nicht im Inland steuerbare Leistung | Service not taxable in Germany |
de.margin_second_hand |
Gebrauchtgegenstände/Sonderregelung | Second-hand goods/special arrangements |
de.margin_art |
Kunstgegenstände/Sonderregelung | Works of art/special arrangements |
de.margin_antiques |
Sammlungsstücke und Antiquitäten/Sonderregelung | Collectors’ items and antiques/special arrangements |
de.travel_agency |
Sonderregelung für Reisebüros | Special arrangements for travel agents |
de.exempt |
Steuerfrei nach :paragraph | Exempt from VAT according to :paragraph |
Standard19, Reduced7 and ZeroRatedPv carry no text (BR-S-10, BR-Z-10). The English texts follow the terms of Directive 2006/112/EC (“exempt”, “intra-Community supply”, “Reverse charge”); the legal texts of the same notices use the same wording.
How presets behave:
- The text is taken in the invoice locale (
->locale()on the builder) when the line is built. The enum presets andVatPreset::exempt()(aQuittio\Country\DE\Tax\CustomVatPreset) default to German when the invoice has no locale; other locales without a text fall back to English. - The preset key stays on the line (
$line->vat->presetKey);DE-USTG-09,DE-USTG-11,DE-USTG-14,DE-USTG-15and small business detection rely on it. - Category, rate, code and text flow into the VAT breakdown (BG-23). Different texts in one category and rate are joined in BT-120; BT-121 is kept only when the codes agree (warning
QUITTIO-EXEMPTION-CONFLICT). - In the PDF, the BT-120 texts of all non-standard categories are listed in
legalNotes. Margin scheme and travel agency rows show no VAT amount (section 14a (6) UStG). - Document level allowances and charges take a category and rate, not a preset:
->charge(VatCategory::StandardRate, '19', amount: '10.00', reason: 'Versand'). - Look presets up by key, e.g. from a database column:
VatPreset::from('de.standard_19')or(new CountryDE)->taxPresets()->get('de.standard_19').de.exemptis not part of the collection.
UStG rules
Quittio\Country\DE\Rules\UstgRules::all() (level L3) applies to every invoice of a German seller, in every format including a plain PDF. Messages and hints are in German and English.
| Rule | Severity (BT, path) | Reported when | Legal basis named | Fix |
|---|---|---|---|---|
DE-USTG-01 |
error (BG-5 seller.address, BG-8 buyer.address) |
The seller lacks street (address line 1), postcode or city; or the buyer lacks street or city, or a buyer in DE lacks the postcode. The buyer part is skipped for small amount invoices. |
§ 14 Abs. 4 Nr. 1 UStG | Add street, postcode and city for seller and buyer. |
DE-USTG-02 |
error (BT-31 seller.vatId) |
The seller has neither a VAT identifier (BT-31) nor a tax number (BT-32) and there is no tax representative (BG-11). Skipped for small amount invoices. | § 14 Abs. 4 Nr. 2 UStG | ->vatId() or ->taxNumber() on the seller. |
DE-USTG-03 |
error (BT-72 delivery.actualDeliveryDate); warning for prepayment invoices (386) |
No delivery date (BT-72), no invoicing period (BG-14) and not every line has a period (BG-26). Skipped for small amount invoices (§ 33 UStDV) and small business invoices (§ 34a UStDV). | § 14 Abs. 4 Nr. 6 UStG | ->deliveryDate() (also when it equals the invoice date), ->invoicingPeriod() or a ->period() on every line. |
DE-USTG-04 |
error (BT-22 notes) |
A self-billed type code (389, 261, 527) and no note contains “Gutschrift” or one of: self-billing, self billing, self-billed invoice, autofacturation, autofactura, autofatturazione, autofacturação, zelffacturering, samofakturowanie (case-insensitive). | § 14 Abs. 4 Nr. 10 UStG; other EU languages: UStAE 14.5 Abs. 24 | ->selfBilled() adds the note. |
DE-USTG-05 |
error (BT-22 notes) |
The flag de.property_related_service is set and no note contains “14b”. |
§ 14 Abs. 4 Nr. 9 UStG | ->propertyRelatedService() adds the note. |
DE-USTG-06 |
warning per VAT row (BT-120 vatBreakdown[i].exemptionReason) |
A VAT breakdown row of category AE whose exemption reason contains neither “Steuerschuldnerschaft” nor the text of one of the presets ReverseChargeDomestic, ReverseChargeEu and TriangularTransaction in a locale the pack ships (de, en), the invoice locale or the validation locale (case-insensitive). |
§ 14a Abs. 1 und 5 UStG | Use ReverseChargeDomestic, ReverseChargeEu or TriangularTransaction. |
DE-USTG-07 |
error per line (BG-32 lines[i].item.attributes) |
The flag de.new_vehicle is set and a line has no item attributes. |
§ 14a Abs. 4 UStG | ->attribute('Erstzulassung', …), ->attribute('Kilometerstand', …) on every line. |
DE-USTG-08 |
error (BT-112 totals.grossTotal) |
The flag de.small_amount is set and the gross total exceeds 250.00 EUR. Other currencies count with their EUR equivalent (gross total × exchange rate of ->taxCurrency('EUR', $rate)); without that rate the rule always fails. |
§ 33 UStDV | Remove ->smallAmount() and issue a full invoice. |
DE-USTG-09 |
error (BT-151 lines, or BT-102 charges for document level entries) |
A line uses VatPreset::SmallBusiness or the invoice has the small business flag, and a line (sub lines included) or a document level allowance or charge has category S. |
§ 19 UStG; VAT shown on charges counts too (§ 14c Abs. 2 UStG) | VatPreset::SmallBusiness on every line, no standard rated charges. |
DE-USTG-10 |
warning (BT-6 taxCurrency) |
The invoice currency is not EUR, the VAT total is not zero and the VAT accounting currency (BT-6) is not EUR. | Art. 230 MwStSystRL, § 16 Abs. 6 UStG | ->taxCurrency('EUR', $rate, $date). |
DE-USTG-11 |
error (BT-31 seller.vatId, BT-48 buyer.vatId) |
A line uses ReverseChargeEu or TriangularTransaction, or a line of category AE without preset has a buyer in the VAT territory of another member state (services: Monaco counts as France, Northern Ireland does not count), and the seller’s VAT identifier (or the tax representative’s) or the buyer’s VAT identifier is missing. A tax number is not sufficient. |
§ 14a Abs. 1 und 7 UStG | ->vatId() for seller and buyer. |
DE-USTG-12 |
error (BT-151 lines); warning for possible distance sales |
The flag de.small_amount is set and category AE or K is used (error), or the buyer is a consumer and the destination (deliver-to address if it has a country, otherwise the buyer’s address) lies in another member state (warning: a distance sale only if goods are transported there). |
§ 33 Satz 3 UStDV; § 3c UStG | Remove ->smallAmount() and issue a full invoice. |
DE-USTG-13 |
warning (BT-151 lines) |
The flag de.new_vehicle is set and no line, allowance or charge uses category K. A warning, as the exemption may not apply in the individual case. |
§ 14a Abs. 4 UStG | VatPreset::IntraCommunitySupply on the vehicle lines, or remove ->newVehicle(). |
DE-USTG-14 |
error (BT-151 lines) |
A line uses VatPreset::SmallBusiness or the invoice has the small business flag, a line, allowance or charge uses category K and the flag de.new_vehicle is not set. |
§ 19 Abs. 1 Satz 2, Abs. 6 Satz 1 UStG | VatPreset::SmallBusiness on the lines; ->newVehicle() for the intra-community supply of a new vehicle. |
DE-USTG-15 |
warning (BT-2 issueDate) |
A line, allowance or charge uses category K, or a line uses ReverseChargeEu (or category AE without preset for a buyer in another member state), and the invoice date is after the 15th of the month following the supply. The supply date is the latest of the delivery date (BT-72), the end of the invoicing period (BG-14) and the ends of the line periods (BG-26). Skipped without a supply date, for documents that refer to a preceding invoice (BG-3: cancellations, corrections, re-issued and final invoices) and for the type codes 381, 261, 384 and 386. A warning, as a late invoice stays valid. |
§ 14a Abs. 1 Satz 2 und Abs. 3 Satz 1 UStG | Issue these invoices by the 15th of the following month. |
Helpers used by the rules and available to you: UstgRules::isSmallAmountInvoice($invoice), UstgRules::isSmallBusinessInvoice($invoice), UstgRules::hasServiceDate($invoice) and UstgRules::issuingDeadline($invoice) (the 15th of the month after the supply, or null without a supply date).
Leitweg-ID
UstgRules::plausibility() (level L7) checks buyer references (BT-10 buyerReference) shaped like a Leitweg-ID whose check digits are wrong:
| Rule | Severity | Reported when |
|---|---|---|
DE-LEITWEG-ID |
warning | the buyer is a public buyer: the profile is xrechnung or xrechnung-extension or the buyer’s electronic address (BT-49) uses scheme 0204. “The check digits of the Leitweg-ID “:value” (BT-10) are wrong.” |
DE-LEITWEG-ID-SUSPECT |
info | any other buyer in Germany (country DE or none) that is not a consumer. “The buyer reference “:value” (BT-10) is shaped like a Leitweg-ID, but its check digits are wrong. It is not treated as a Leitweg-ID, so the invoice is not issued as XRechnung automatically.” |
A mistyped Leitweg-ID thus does not lead to ZUGFeRD unnoticed; an order number of the same shape (e.g. 4711-01) gets the same info, which you may ignore. Neither finding changes the format.
Quittio\Country\DE\Support\LeitwegId:
| Method | Result |
|---|---|
looksLike(?string $value): bool |
coarse address of 2 to 12 digits, optional fine address of up to 30 letters or digits, two check digits, separated by hyphens |
isValid(?string $value): bool |
shape and check digits (ISO 7064 Mod 97-10) are correct |
checkDigits(string $address): string |
the two check digits for coarse and fine address without hyphens, e.g. checkDigits('0401100012345') → 03 |
Tax number
UstgRules::plausibility() also checks the tax number (Steuernummer, BT-32) of a seller in Germany:
| Rule | Severity | Reported when |
|---|---|---|
DE-TAX-NUMBER |
warning (BT-32 seller.taxNumber) |
the seller’s country is DE and the tax number is neither a valid 13-digit ELSTER number, a 12-digit federal number nor the notation of a federal state, or its check digit is wrong; also when a VAT identifier (DE…) was given as tax number. A Bavarian notation without the tax office number (815/08152) cannot be checked and is accepted. |
The checks follow “Prüfung der Steuer- und Steueridentifikationsnummer” of ELSTER (Bayerisches Landesamt für Steuern): the ELSTER format FFFF0BBBUUUUP (NRW FFFF0BBBBUUUP) with the 0 at the 5th position, the excluded districts (000, 998, 999; at least 100 in the Bavarian group; NRW 0000, 0998, 0999 and UUUP above 0009) and the check digit procedure of the state: modified 11 procedure (Rhineland-Palatinate), 2 procedure (Baden-Württemberg, Hesse, Schleswig-Holstein), 11 procedure with the factors of the state (all others; Berlin with the variants A and B by tax office and district). Whether the tax office exists is not checked. A notation without the state (e.g. 93/815/08150) is valid when it is valid for any state that uses this notation.
Quittio\Country\DE\Support\TaxNumber:
| Method | Result |
|---|---|
TaxNumber::parse(string $value, ?string $state = null): ?TaxNumber |
the valid number in any notation (198/815/08152, 9198081508152, 919881508152, 053 815 08158 …), or null; $state (ISO 3166-2:DE, e.g. BY) restricts a state notation to that state |
TaxNumber::validate(string $value, ?string $state = null): ?bool |
true valid, false invalid, null plausible but not checkable |
TaxNumber::looksLikeVatId(string $value): bool |
a VAT identifier (DE + 9 digits) |
->elster, ->state, ->formatted() |
13-digit ELSTER format, state, notation on tax notices (e.g. 198/815/08152, 400/8150/8159) |
XRechnung rules
Quittio\Country\DE\Rules\XRechnungRules (level L3) are the rules of the XRechnung profiles: they apply whenever the profile is xrechnung or xrechnung-extension, whatever the seller’s country. Severity follows the flags of the official Schematron: “muss” rules (fatal) are errors, “soll” rules (warning) warnings and BR-DE-TMP-32 (information) an info; the KoSIT validator remains authoritative (see validation).
| Rule | Severity | Reported when |
|---|---|---|
BR-DE-1 |
error | no payment instructions (BG-16) |
BR-DE-2 |
error | no seller contact (BG-6) |
BR-DE-3, BR-DE-4 |
error | seller city (BT-37) or postcode (BT-38) missing |
BR-DE-5, BR-DE-6, BR-DE-7 |
error | seller contact without name (BT-41), phone (BT-42) or e-mail (BT-43) |
BR-DE-8, BR-DE-9 |
error | buyer city (BT-52) or postcode (BT-53) missing |
BR-DE-10, BR-DE-11 |
error | a deliver-to address (BG-15) without city (BT-77) or postcode (BT-78) |
BR-DE-15 |
error | no buyer reference (BT-10); for public buyers the Leitweg-ID |
BR-DE-16 |
error | a category other than O is used and the seller has no VAT identifier (BT-31), tax number (BT-32) or tax representative (BG-11) |
BR-DE-17 |
warning | type code (BT-3) not one of 326, 380, 384, 389, 381, 875, 876, 877 |
BR-DE-18 |
error | a line of the payment terms text (BT-20) starts with # but does not match #SKONTO#TAGE=n#PROZENT=n.nn# (optionally BASISBETRAG=n.nn#) |
BR-DE-19 |
warning | SEPA credit transfer (58) with an invalid IBAN (BT-84) |
BR-DE-20 |
warning | SEPA direct debit (59) with an invalid debited IBAN (BT-91) |
BR-DE-22 |
error | attachment file names (BT-125-2) not unique |
BR-DE-23-a, BR-DE-23-b |
error | credit transfer (30, 58) without BG-17, or with card (BG-18) or direct debit (BG-19) data |
BR-DE-24-a, BR-DE-24-b |
error | card payment (48, 54, 55) without BG-18, or with BG-17 or BG-19 data |
BR-DE-25-a, BR-DE-25-b |
error | direct debit (59) without BG-19, or with BG-17 or BG-18 data |
BR-DE-26 |
warning | corrected invoice (384) without preceding invoice reference (BG-3) |
BR-DE-27 |
warning | seller phone (BT-42) with fewer than three digits |
BR-DE-28 |
warning | seller e-mail (BT-43) without exactly one @ surrounded by at least two characters |
BR-DE-30, BR-DE-31 |
error | direct debit (BG-19) without creditor identifier (BT-90) or debited account (BT-91) |
BR-DE-TMP-32 |
info (flag information in the Schematron) |
no delivery date (BT-72), invoicing period (BG-14) or period on every line (BG-26) |
BR-DEX-15 |
error | sub lines in XRechnung CII (sub lines need the extension in UBL) |
DE-XRECHNUNG-EXT-01 |
error | a credit note in profile xrechnung-extension with syntax UBL; the official validator configuration has no scenario for it. Cancel with the strategy negative_invoice (type code 380, negative amounts). |
Quittio writes structured cash discounts itself (#SKONTO#TAGE=7#PROZENT=2.00#), so BR-DE-18 only concerns lines you put into the payment terms text.
Format choice
Quittio\Country\DE\Formats\GermanFormatRule decides only where German law prescribes a format, and returns null otherwise:
| Condition | Format | Profile |
|---|---|---|
The buyer reference (BT-10) is a Leitweg-ID with valid check digits, or the buyer’s electronic address (BT-49) uses scheme 0204 (GermanFormatRule::LEITWEG_SCHEME) |
xrechnung-cii |
xrechnung |
Small amount invoice whose simplifications apply (UstgRules::isSmallAmountInvoice()), seller and buyer in the German Inland (section 1 (2) UStG, i.e. without Büsingen and Heligoland; an address without country code counts as domestic) |
pdf |
– |
| otherwise | – (null) | – |
Everything else follows the general order of the resolver:
- Domestic business invoices: the Peppol lookup of the resolver (if one is configured), then
zugferdwith the configured profile (en16931by default; EXTENDED when EXTENDED features are used). - Consumers:
pdf. - The e-invoicing obligation of section 14 (2) UStG covers supplies between businesses established in Germany only. Sellers outside the EU invoicing a German business get
pdffrom the resolver, sellers in other member stateszugferd; buyers in Büsingen or Heligoland are outside the EU VAT territory and getpdf. - German public buyers receive XRechnung whatever the seller’s country: for cross-border invoices to a buyer in Germany the rule runs as the buyer’s pack rule.
->buyerReference('04011000-12345-03') // Leitweg-ID: XRechnung
->buyer(fn ($b) => $b->electronicAddress('04011000-12345-03', '0204')) // or by endpoint scheme
- Order numbers shaped like a Leitweg-ID (e.g.
4711-01) do not switch to XRechnung. A Leitweg-ID with a typo does not either; validation reports it asDE-LEITWEG-ID-SUSPECT(info, see Leitweg-ID). Correct it, set the0204endpoint as well, or choose the format explicitly (->format('xrechnung-ubl')). - An explicit
->format()or configuredquittio.defaults.formatalways wins.
XRechnung profiles
XRechnung30 |
XRechnung30Extension |
|
|---|---|---|
| Key, name | xrechnung, XRechnung 3.0 |
xrechnung-extension, XRechnung 3.0 Extension |
| Specification identifier (BT-24) | urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0 |
urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0#conformant#urn:xeinkauf.de:kosit:extension:xrechnung_3.0 |
| Level, syntaxes | EN 16931; CII and UBL | as XRechnung 3.0; sub lines only in UBL |
| Business process (BT-23) default | urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 |
same |
| Additionally required | BT-10, BT-34, BT-49, BG-6, BG-16 | same |
| Embedded in a PDF | xrechnung.xml, XMP conformance level XRECHNUNG, fx:Version 3.0 |
same |
| Extras | VAT rate written in every breakdown, 0 for category O (BR-DE-14); cash discounts as #SKONTO# lines in BT-20; rules above |
sub lines, third party payments (BG-DEX-09, UBL invoices only), identifier schemes XR01 to XR03 in the ICD and EAS code lists |
The profiles exist only when the pack is registered; without it the formats xrechnung-cii and xrechnung-ubl cannot be rendered and incoming XRechnung files are reported as an unknown profile (QUITTIO-READER-UNKNOWN-PROFILE).
$quittio->make($invoice)->format('xrechnung-ubl')->render(); // XRechnung 3.0, UBL
$quittio->make($invoice)->format('xrechnung-ubl')->profile('xrechnung-extension')->render(); // extension
$quittio->make($invoice)->format('zugferd')->profile('xrechnung')->render(); // PDF/A-3 with xrechnung.xml
The automatic choice always uses xrechnung, also when the invoice carries extension data; the specification identifier never switches by itself. Choose xrechnung-extension explicitly for extension data; with xrechnung, third party payments fail with QUITTIO-PROFILE-XRECHNUNG-EXTENSION-ONLY.
Builder macros
Quittio\Country\DE\Builder\GermanInvoiceMacros adds these methods to InvoiceBuilder:
| Macro | Writes into the invoice |
|---|---|
smallAmount() |
flag de.small_amount |
propertyRelatedService(string $locale = 'de') |
flag de.property_related_service and the note LegalTexts::PROPERTY_RETENTION (subject REG) |
newVehicle(string $locale = 'de') |
flag de.new_vehicle and the note LegalTexts::NEW_VEHICLE (subject REG); the vehicle details are your line attributes (DE-USTG-07), the vehicle lines use VatPreset::IntraCommunitySupply (DE-USTG-13), also for small businesses (DE-USTG-14) |
selfBilled(string $locale = 'de') |
type code 389 and the note LegalTexts::SELF_BILLING (subject REG) |
deductAdvance(InvoiceData $advance, string $locale = 'de') |
preceding invoice (BG-3), prepaid amount (BT-113) and the note LegalTexts::ADVANCE_DEDUCTION (subject REG) |
consumerDefaultNotice(string $locale = 'de') |
the note LegalTexts::CONSUMER_DEFAULT (subject PMT) |
The $locale parameter selects the text; it is independent of the invoice locale. A locale with a region uses the text of its language (en_GB → English); locales without a text of the pack get the German text (LegalTexts::text()). Notes with subject REG are listed in the PDF’s legalNotes, PMT notes with the other notes.
Final invoices (deductAdvance)
A final invoice (section 14 (5) sentence 2 UStG) states the full service with its full VAT and deducts the advance payments received:
use Quittio\Core\Calculation\En16931Calculator;
$advance = (new En16931Calculator)->calculate($advanceInvoice); // or $rendered->data()
$final = InvoiceBuilder::make()
// number, dates, parties, payment …
->line(fn ($l) => $l->name('Website')->quantity(1, UnitCode::Piece)->netPrice('3000.00')->vat(VatPreset::Standard19))
->deductAdvance($advance);
With an advance invoice RE-2026-0040 of 1,000.00 EUR net plus 19 % VAT, the final invoice shows a gross total of 3,570.00 EUR, VAT 570.00 EUR, prepaid 1,190.00 EUR and an amount due of 2,380.00 EUR, with the note:
Abzüglich Anzahlung gemäß Rechnung RE-2026-0040 vom 01.09.2026: Entgelt 1.000,00 € zzgl. 19 % Umsatzsteuer 190,00 € (§ 14 Abs. 5 Satz 2 UStG).
- The advance invoice must be calculated (
totalsset), have a positive gross total and be in the currency of the final invoice; otherwise anInvalidArgumentExceptionis thrown. Set->currency()before calling the macro. - Only documents that can be advance invoices are accepted (
GermanInvoiceMacros::ADVANCE_TYPES): type codes 380 (commercial invoice), 386 (prepayment invoice), 326 (partial invoice), 875 (partial construction invoice) and 389 (self-billed invoice). Credit notes, debit notes, corrected invoices (384), final invoices (876, 877) and cancellations by negative invoice are rejected. - The deducted amount is the advance invoice’s gross total plus its cash rounding (BT-114).
- Every VAT row of the advance invoice is itemised: standard rated rows as “Entgelt … zzgl. … Umsatzsteuer …” (“net … plus … VAT …”,
LegalTexts::ADVANCE_DEDUCTION_TAXED), other rows as “Entgelt … ohne Umsatzsteuer” (“net … without VAT”,LegalTexts::ADVANCE_DEDUCTION_UNTAXED); rows are separated by semicolons. - The whole note is in one language: the language of
$localeif the pack has it (de,en), otherwise German. Amounts and dates are formatted in$localewhen the note is in its language (e.g.de_AT), otherwise in the language of the note (frgives the German note with German formatting). - Call it once per advance invoice; the prepaid amounts add up.
- The macro does not check whether the advance invoice was paid; deduct only payments received.
Small amount invoices
->smallAmount() marks an invoice of at most 250.00 EUR gross (Flags::SMALL_AMOUNT_LIMIT) under section 33 UStDV. As long as no reverse charge (AE) or intra-community supply (K) is involved, it relaxes:
DE-USTG-01: the buyer’s address is not required (the seller’s still is);DE-USTG-02: no tax number or VAT identifier;DE-USTG-03: no service date;- the mandatory PDF output for the tax number and the service date;
- the format:
pdfinstead ofzugferdwhen seller and buyer are in the German Inland (a Leitweg-ID still leads to XRechnung; see format choice).
DE-USTG-08 enforces the limit and DE-USTG-12 excludes reverse charge and intra-community supplies (section 33 sentence 3 UStDV); such invoices need all mandatory information and are not rendered as plain PDF. Possible distance sales keep the simplifications with a warning.
Small businesses
- Use
VatPreset::SmallBusinesson every line. An invoice whose lines all use it is a small business invoice under section 34a UStDV: no service date is required (DE-USTG-03) or checked in the PDF; the tax number or VAT identifier is still required (DE-USTG-02), and the preset’s text (BT-120) appears in the PDF. - Mark the seller as a small business so that VAT is rejected (
DE-USTG-09):quittio.seller.small_business = true(applied bySeller::apply($builder)), the Managed issuer settingtax_settings.small_business, or->flag(Meta::FLAG_SMALL_BUSINESS).Flags::SMALL_BUSINESSis the same flag (small_business). The flag does not set presets or texts. - A small business cannot use the exemption for intra-community supplies (category
K,DE-USTG-14, section 19 (1) sentence 2 UStG); only the intra-community supply of a new vehicle (->newVehicle()) is excepted (section 19 (6) sentence 1 UStG). - Without a VAT identifier, EN 16931 needs a seller identifier (BT-29) or legal registration (BT-30) (BR-CO-26); in Laravel set
quittio.seller.identifiersorlegal_registration(see configuration).
use Quittio\Core\Builder\InvoiceBuilder;
use Quittio\Laravel\Seller;
$builder = Seller::apply(InvoiceBuilder::make()) // quittio.seller.small_business = true
->line(fn ($l) => $l->name('Beratung')->quantity(1, UnitCode::Hour)->netPrice('80.00')->vat(VatPreset::SmallBusiness));
Reverse charge, intra-community supplies and VAT identifiers
- Reverse charge rows (
AE) must name “Steuerschuldnerschaft des Leistungsempfängers” (DE-USTG-06, warning). The texts of the reverse charge presets are accepted in every locale the pack ships, so English invoices with the presets pass as well; own texts must contain “Steuerschuldnerschaft”. - Reverse charge in another member state and triangular transactions need the VAT identifiers of seller and buyer (
DE-USTG-11); the rule does not apply toReverseChargeDomestic(section 13b UStG). - Invoices for intra-community supplies and for services in another member state (
ReverseChargeEu) must be issued by the 15th of the month following the supply (DE-USTG-15, warning; section 14a (1) sentence 2 and (3) sentence 1 UStG). - The PDF must show the reverse charge or exemption note and the VAT identifiers (see mandatory PDF output).
- A seller tax representative (BG-11,
->taxRepresentative($name, $vatId, $address)) satisfiesDE-USTG-02andBR-DE-16; its VAT identifier satisfiesDE-USTG-11and the mandatory output. - The pack does not check the format of a German tax number (Steuernummer). VAT identifiers are checked by the core plausibility rules (
QUITTIO-VATID-FORMAT, forDEnine digits; optionallyQUITTIO-VIES), which also warn about categoryKwith a domestic or non-EU destination andGwithin the EU (see validation).
Self-billing (Gutschrift)
->selfBilled() sets type code 389 and the note “Gutschrift” (English: “Gutschrift (self-billed invoice)”). In German, a self-billed invoice is titled “Gutschrift” (English “Self-billed invoice”); the PDF shows the buyer as issuer. DE-USTG-04 requires the label on every self-billed type code (389, 261, 527), and the mandatory output requires the title and the “Gutschrift” note in the PDF. “Gutschrift” is used only for self-billing; commercial credit notes and cancellations carry other titles.
Cancellations and corrections
CountryDE::correctionPolicy(): cancellation strategy credit_note, correction strategy cancel_and_reissue, with the titles
| Kind | German | English |
|---|---|---|
cancellation |
Stornorechnung | Cancellation invoice |
cancellation_self_billed |
Storno-Gutschrift | Cancellation of the self-billed invoice (Storno-Gutschrift) |
correction |
Rechnungskorrektur | Corrected invoice |
self_billed |
Gutschrift | Self-billed invoice (Gutschrift) |
cancellation and cancellation_self_billed title the documents of Quittio::cancellation(); correction titles a Managed corrected invoice (384). self_billed titles self-billed invoices (389, 261, 527) unless the presentation sets a title; locales without an own title fall back to English (“Self-billed invoice (Gutschrift)”), which keeps the word “Gutschrift” required by section 14 (4) no. 10 UStG. Both cancellation strategies are permitted in Germany; change them with quittio.corrections.cancellation_strategy and quittio-managed.correction.strategy. For the XRechnung extension in UBL use negative_invoice (DE-XRECHNUNG-EXT-01).
Mandatory PDF output
CountryDE::mandatoryOutput() adds these values to the base fields of the mandatory field guard (paths without a value are skipped):
| Condition | Access key | Description | Legal basis |
|---|---|---|---|
| not a small amount invoice, and the seller has a VAT identifier or tax number, or a tax representative with VAT identifier | seller.vatId\|seller.taxNumber\|taxRepresentative.vatId |
tax number or VAT identifier of the seller | § 14 Abs. 4 Nr. 2 UStG |
| not a small amount or small business invoice, with BT-72 or BG-14 | servicePeriod\|deliveryDate\|delivery.date |
date of the supply or service | § 14 Abs. 4 Nr. 6 UStG |
| the same, without BT-72 and BG-14 | servicePeriod\|lineGroups[*].lines[*].period\|lines[*].period |
date of the supply or service | § 14 Abs. 4 Nr. 6 UStG |
| self-billed type code | title, and the path of the first note containing “Gutschrift” |
label and note “Gutschrift” | § 14 Abs. 4 Nr. 10 UStG |
flag de.property_related_service |
path of the retention note (its text or a note containing “14b”) | retention notice for property related services | § 14 Abs. 4 Nr. 9 UStG |
each VAT row of category AE or K |
vatRows[i].label or the legal note with its BT-120 text |
reverse charge or exemption note | § 14a UStG |
category AE or K used |
buyer.vatId |
VAT identifier of the buyer | § 14a Abs. 1 und 3 UStG |
category K used, or AE with a buyer country different from the seller’s |
seller.vatId\|taxRepresentative.vatId |
VAT identifier of the seller | § 14a Abs. 1 und 3 UStG |
The default templates print all of them.
Legal texts
Quittio\Country\DE\Texts\LegalTexts::catalog() (also CountryDE::legalTexts()), fallback locale de (LegalTexts::FALLBACK_LOCALE):
| Constant (key) | German | Added by |
|---|---|---|
SELF_BILLING (self_billing) |
Gutschrift | selfBilled() |
PROPERTY_RETENTION (property_retention) |
Sie sind gesetzlich verpflichtet, diese Rechnung, einen Zahlungsbeleg oder eine andere beweiskräftige Unterlage zwei Jahre lang aufzubewahren (§ 14b Abs. 1 Satz 5 UStG). | propertyRelatedService() |
REVERSE_CHARGE (reverse_charge) |
Steuerschuldnerschaft des Leistungsempfängers | – (text of VatPreset::ReverseChargeEu) |
INTRA_COMMUNITY_SUPPLY (intra_community_supply) |
Steuerfreie innergemeinschaftliche Lieferung (§ 4 Nr. 1b i. V. m. § 6a UStG) | – (text of VatPreset::IntraCommunitySupply) |
EXPORT (export) |
Steuerfreie Ausfuhrlieferung (§ 4 Nr. 1a i. V. m. § 6 UStG) | – (text of VatPreset::Export) |
SMALL_BUSINESS (small_business) |
Steuerfreie Leistung nach § 19 Abs. 1 UStG (Steuerbefreiung für Kleinunternehmer). | – (text of VatPreset::SmallBusiness) |
PREPAYMENT (prepayment) |
Die Umsatzsteuer entsteht mit Vereinnahmung der Anzahlung. Die Leistung wird voraussichtlich am :date erbracht. | – |
ADVANCE_DEDUCTION (advance_deduction) |
Abzüglich Anzahlung gemäß Rechnung :number vom :date: :amounts (§ 14 Abs. 5 Satz 2 UStG). | deductAdvance() |
ADVANCE_DEDUCTION_TAXED (advance_deduction_taxed) |
Entgelt :net zzgl. :rate Umsatzsteuer :vat | deductAdvance(), part of :amounts |
ADVANCE_DEDUCTION_UNTAXED (advance_deduction_untaxed) |
Entgelt :net ohne Umsatzsteuer | deductAdvance(), part of :amounts |
CONSUMER_DEFAULT (consumer_default) |
Sie kommen spätestens in Verzug, wenn Sie nicht innerhalb von 30 Tagen nach Fälligkeit und Zugang dieser Rechnung zahlen (§ 286 Abs. 3 BGB). | consumerDefaultNotice() |
NEW_VEHICLE (new_vehicle) |
Innergemeinschaftliche Lieferung eines neuen Fahrzeugs (§ 14a Abs. 4 UStG); die Fahrzeugmerkmale nach § 1b Abs. 2 und 3 UStG sind bei der Position angegeben. | newVehicle() |
Every text also exists in English. REVERSE_CHARGE, INTRA_COMMUNITY_SUPPLY, EXPORT and SMALL_BUSINESS are the exemption reasons of the named tax presets in every locale the pack ships (CountryDE::locales()), so a notice has one wording in BT-120 and in your notes, including overrides of tax.php. No text is added automatically by validation or rendering. LegalTexts::text($key, $locale, $replace) returns a text in the language of $locale (en_GB → English) or in German; LegalTexts::localeOf($key, $locale) names the catalog locale it uses. Texts marked “–” are for your own notes:
use Quittio\Core\Enums\NoteSubjectCode;
use Quittio\Country\DE\Texts\LegalTexts;
$builder->type('386')->note(
LegalTexts::catalog()->get(LegalTexts::PREPAYMENT, 'de', ['date' => '01.11.2026']),
NoteSubjectCode::RegulatoryInformation,
);
Prepayment invoices (386) without a service date get a DE-USTG-03 warning instead of an error; name the expected date as above.
Meta flags
Quittio\Country\DE\Support\Flags:
| Constant | Value | Set by | Effect |
|---|---|---|---|
SMALL_AMOUNT |
de.small_amount |
smallAmount() |
small amount invoice: relaxed rules, PDF format, DE-USTG-08, DE-USTG-12 |
PROPERTY_RELATED_SERVICE |
de.property_related_service |
propertyRelatedService() |
DE-USTG-05, mandatory output |
NEW_VEHICLE |
de.new_vehicle |
newVehicle() |
DE-USTG-07, DE-USTG-13, DE-USTG-14 (exception) |
SMALL_BUSINESS |
small_business (Meta::FLAG_SMALL_BUSINESS) |
seller configuration, Managed issuer, ->flag() |
DE-USTG-09, DE-USTG-14 |
SMALL_AMOUNT_LIMIT |
250.00 |
– | limit of DE-USTG-08 in EUR |
Retention
CountryDE::retention() returns new RetentionRule(8, fromEndOfCalendarYear: true): invoices are kept for 8 years from the end of the calendar year of issue (section 14b UStG, section 147 AO as amended in 2025). An invoice issued on 3 October 2026 is kept until 31 December 2034. The Managed layer stores this date (retention_until); quittio-managed.retention.years may only lengthen it.
Dunning law
CountryDE::dunningLaw(): base rate plus 9 points for business debtors and 5 points for consumers, flat rate 40.00 EUR for business debtors, day count 365, consumer default after 30 days (with notice), reference rate “Basiszinssatz (§ 247 BGB)”. Quittio’s documents do not apply these values themselves; use DefaultInterest and pass the results to a payment reminder (see documents: payment reminders).
Base rate table
Quittio\Country\DE\Dunning\BaseRateTable holds the base rate (§ 247 BGB), which changes on 1 January and 1 July. BaseRateTable::PUBLISHED:
| Valid from | Rate in % |
|---|---|
| 2023-01-01 | 1.62 |
| 2023-07-01 | 3.12 |
| 2024-01-01 | 3.62 |
| 2024-07-01 | 3.37 |
| 2025-01-01 | 2.27 |
| 2025-07-01 | 1.27 |
| 2026-01-01 | 1.27 |
| 2026-07-01 | 1.52 |
| Method | Result |
|---|---|
__construct(array $rates = self::PUBLISHED) |
ISO date => rate in percent, sorted by date |
withRate(Date\|string $from, string $rate): self |
new table with an additional or replaced rate |
rateOn(Date $date): BigDecimal |
rate on a day; InvalidArgumentException before the first entry and from validUntil() on |
periods(Date $from, Date $until): list<array{from: Date, until: Date, rate: BigDecimal}> |
periods with a constant rate (start inclusive, end exclusive), split where the rate changes; a table entry that keeps the rate (e.g. 2026-01-01) starts no new period; InvalidArgumentException if $until is after validUntil() |
lastChange(): Date |
date of the last entry, e.g. to warn when the table is outdated |
validUntil(): Date |
first day without a known rate: the next 1 January or 1 July after the last entry (with the published table: 2027-01-01) |
Rates after the last entry are unknown until they are published: interest for days from 1 January 2027 is rejected instead of silently using the old rate. There are three ways to add new rates, so that no new Quittio release is needed:
-
Laravel configuration (
config/quittio-de.php, publish it withphp artisan vendor:publish --tag=quittio-de-config):'base_rates' => ['2027-01-01' => '1.77'], -
Automatic update from the Deutsche Bundesbank (Laravel):
php artisan quittio:base-rate:updatefetches the seriesBBIN1.M.DE.BBK.BBKBAS2.EUR.ME(Basiszinssatz gemäß BGB) from the statistics API and stores the rates the shipped table does not contain inquittio-de.base_rate_file(defaultstorage/app/quittio/base-rates.json), also those before 2023 for older claims. It writes the file only when something changed;--dry-runshows the rates without writing. A value that differs from the shipped table is reported, and the Bundesbank value is used. Schedule it, e.g. weekly, so that the rate of 1 January and 1 July is there as soon as the Bundesbank has published it:// routes/console.php Schedule::command('quittio:base-rate:update')->weekly(); -
Without Laravel:
BaseRates::register(['2027-01-01' => '1.77'])once at start-up,BundesbankBaseRates::fetch()to fetch the rates yourself, or a table for one calculation:$rates = (new BaseRateTable)->withRate('2027-01-01', '1.77'); $interest = new DefaultInterest($rates);
DefaultInterest without a table uses BaseRates::table(): the shipped table, then the rates of the file, then those of quittio-de.base_rates (the configuration wins for the same date). The service provider Quittio\Country\DE\Laravel\GermanyServiceProvider registers file and configuration at boot; Laravel discovers it automatically.
Quittio\Country\DE\Dunning\BaseRates:
| Method | Result |
|---|---|
register(array $rates): void |
adds or replaces rates (ISO date => percent); only 1 January and 1 July, values such as '1.77' or '-0.88', otherwise InvalidArgumentException |
table(): BaseRateTable |
the shipped table with the registered rates |
registered(): array / reset(): void |
the registered rates; removes them (tests) |
Quittio\Country\DE\Dunning\BundesbankBaseRates:
| Method | Result |
|---|---|
__construct(?Closure $http = null, int $timeout = 15) |
$http receives URL and timeout and returns the response body (for tests or an own HTTP client); default file_get_contents() |
fetch(string $since = '2002-01'): array |
the January and July values from the given month on, ISO date => percent; RuntimeException when the API cannot be reached or returns no rates |
parse(string $csv): array |
reads the CSV download (2026-07;1,52;…) |
Default interest
Quittio\Country\DE\Dunning\DefaultInterest:
| Method | Result |
|---|---|
__construct(?BaseRateTable $rates = null, ?DunningLaw $law = null) |
without a table, BaseRates::table() is used (shipped and registered rates); without a law, CountryDE::dunningLaw() (points, flat rate and day count) |
calculate(Money $amount, Date $from, Date $until, bool $consumer = false, string $locale = 'de'): list<InterestCharge> |
one charge per base rate period from $from (first day with interest) to $until (exclusive); rate = base rate + 9 points, or + 5 points with $consumer; empty for a non-positive amount or $until not after $from; InvalidArgumentException for days the table does not cover |
flatRate(string $currency = 'EUR'): ?Money |
40.00 flat rate for business debtors (§ 288 (5) BGB), once per claim |
use Brick\Money\Money;
use Quittio\Core\Documents\DocumentBuilder;
use Quittio\Core\Support\Amount;
use Quittio\Core\Support\Date;
use Quittio\Country\DE\Dunning\DefaultInterest;
$interest = new DefaultInterest;
$reminder = DocumentBuilder::paymentReminder(1)
->number('M-2026-0003')
->seller(Seller::fromConfig())
->buyer($buyer)
->openItem('RE-2026-0042', '2026-06-01', '1190.00', dueDate: '2026-06-15');
foreach ($interest->calculate(Money::of('1190.00', 'EUR'), Date::parse('2026-06-16'), Date::parse('2026-10-04')) as $charge) {
$reminder->interest(Amount::format($charge->base), $charge->ratePercent, $charge->from, $charge->until, $charge->description);
}
$reminder->fee('Pauschale nach § 288 Abs. 5 BGB', Amount::format($interest->flatRate()));
The descriptions name the rate, the base rate, the points and the last day with interest, e.g. “Verzugszinsen 10,27 % p. a. (Basiszinssatz 1,27 % + 9,00 Prozentpunkte) vom 16.06.2026 bis 30.06.2026”; any other locale gives “Default interest 10.27 % p.a. (base rate 1.27 % + 9.00 points) from 2026-06-16 to 2026-06-30”. The charges use the day count of the dunning law (InterestCharge::$dayCount; 365 for CountryDE::dunningLaw(), so a leap year yields 366/365 of the annual rate). A charge covers a whole period with the same base rate: interest from 1 December 2025 to 28 February 2026 is one charge, although the table has an entry for 1 January 2026.
The flat rate is due only from business debtors, once per claim, and the documents layer does not track which claims already carried it. For consumers, ->consumerDefaultNotice() adds the notice on default after 30 days to the invoice.
BMF error classes
Quittio\Country\DE\Rules\BmfErrorClass maps findings to the error classes of the BMF letter of 15 October 2025:
| Case | Value | Findings | label('de') |
label('en') |
|---|---|---|---|---|
Format |
format |
levels L1, L2, L4, L6 | Formatfehler (Rz. 6a) | Format error (marginal no. 6a) |
BusinessRule |
business_rule |
levels L3, L5 | Geschäftsregelfehler (Rz. 6b) | Business rule error (marginal no. 6b) |
Content |
content |
every DE-USTG-* finding and level L7 |
Inhaltsfehler (Rz. 35a) | Content error (marginal no. 35a) |
BmfErrorClass::of(ValidationMessage $message) classifies one finding; label() returns German for de, English otherwise; voidsEInvoice() is true for Format only (the document is then an “other invoice”). (new CountryDE)->errorClassification($result) groups the errors and warnings of a ValidationResult by class value:
$classes = (new CountryDE)->errorClassification($result); // ['format' => [...], 'business_rule' => [...], 'content' => [...]]
DIN 5008 letter layouts
Quittio\Country\DE\Layouts\Din5008 provides formA(), formB() and all() (form A, form B and free). Form B is the German default. Values in millimetres, as in DIN 5008:2020 (unchanged since the 2011 edition, which enlarged the address field of both forms from 40 to 45 mm and moved the Rücksendeangabe into the Zusatz- und Vermerkzone):
din5008a (Din5008::FORM_A) |
din5008b (Din5008::FORM_B) |
|
|---|---|---|
| Address field from top / left | 27 / 20 | 45 / 20 |
| Address field width × height | 85 × 45 | 85 × 45 |
| Zusatz- und Vermerkzone (above the recipient) | 17.7 (five lines, filled from the bottom), its last 5 mm hold the sender line (Rücksendeangabe) | same |
| Anschriftzone (recipient, six lines) | 27.3, from 44.7 | 27.3, from 62.7 |
| Fold marks | 87 and 192 | 105 and 210 |
| Punch mark | 148.5 | 148.5 |
| Letterhead height in the default templates (top margin 20) | 7 | 25 |
The address field keeps its position with a logo. Select a layout per invoice with ->presentation(fn ($p) => $p->with(['letterLayout' => 'din5008a'])) or for all invoices with quittio.pdf.layout; fold and punch marks are printed with quittio.pdf.fold_marks = true (core option fold_marks). See templates.
Translations
The pack ships German and English catalogs in resources/lang/{de,en}/:
rules.php: message and hint of every rule (BR-DE-*,BR-DEX-15,DE-XRECHNUNG-EXT-01,DE-USTG-*,DE-LEITWEG-ID,DE-LEITWEG-ID-SUSPECT);tax.php: the exemption reason texts of the presets, includingde.exemptforVatPreset::exempt()(placeholder:paragraph).
They are registered as the namespace quittio-de (e.g. quittio-de::rules.DE-USTG-03.message); CountryDE::registerTranslations() registers them lazily, so presets work without a Quittio instance. Other locales fall back to English. With Laravel, Laravel’s translator is used, so the usual override files apply: lang/vendor/quittio-de/{locale}/rules.php and tax.php, also for additional locales.
CountryDE::locales() lists the locales the pack ships (the directories of resources/lang). Texts defined in PHP are not translatable through files: the legal texts (German and English, fallback German; the four notices taken from the presets follow tax.php), the correction policy titles (fallback English), the BMF labels and the default interest descriptions (German for de, English otherwise).
Known limitations
- The legal wording and references of presets, rules and texts are proposals that must be confirmed by a tax adviser (see the legal review in the README); the default correction strategy and the cancellation of self-billed invoices are under review as well.
- Final invoices: only the EN 16931 variant (
deductAdvance()) exists; the EXTENDED variant and the handling of unpaid advance invoices are open. - The shipped base rate table ends with 1 July 2026; later rates come from
quittio-de.base_ratesorphp artisan quittio:base-rate:update(see Base rate table). - Default dunning fees and flat rates are under review; the documents layer does not track the § 288 (5) BGB flat rate per claim.
- Distance sales (section 3c UStG) cannot be told from the data;
DE-USTG-12only warns about possible ones. - Tax numbers are checked for format and check digit, not against the list of tax offices; territories without a distinct postcode cannot be detected.
- The pack defines no buyer country rules, exporters (e.g. DATEV) or readers yet.