mock-einvoice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mock_einvoice-0.1.0.dist-info/METADATA +383 -0
- mock_einvoice-0.1.0.dist-info/RECORD +32 -0
- mock_einvoice-0.1.0.dist-info/WHEEL +5 -0
- mock_einvoice-0.1.0.dist-info/entry_points.txt +2 -0
- mock_einvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
- mock_einvoice-0.1.0.dist-info/top_level.txt +1 -0
- mockeinvoice/__init__.py +77 -0
- mockeinvoice/__main__.py +6 -0
- mockeinvoice/buyer.py +301 -0
- mockeinvoice/model.py +456 -0
- mockeinvoice/response.py +337 -0
- mockeinvoice/rules/LICENSE-EUPL-1.2.txt +194 -0
- mockeinvoice/rules/__init__.py +250 -0
- mockeinvoice/rules/calculation.py +215 -0
- mockeinvoice/rules/codelists.py +372 -0
- mockeinvoice/rules/en16931.py +644 -0
- mockeinvoice/rules/en16931_codes.py +180 -0
- mockeinvoice/rules/en16931_ubl.py +341 -0
- mockeinvoice/rules/en16931_vat.py +429 -0
- mockeinvoice/rules/german.py +281 -0
- mockeinvoice/rules/peppol.py +780 -0
- mockeinvoice/rules/peppol_de.py +32 -0
- mockeinvoice/rules/peppol_response.py +308 -0
- mockeinvoice/rules/published.py +1319 -0
- mockeinvoice/rules/tree.py +171 -0
- mockeinvoice/rules/ublsyntax.py +790 -0
- mockeinvoice/rules/untdid.py +23 -0
- mockeinvoice/rules/xrechnung.py +94 -0
- mockeinvoice/server.py +477 -0
- mockeinvoice/specification.py +75 -0
- mockeinvoice/supplier.py +189 -0
- mockeinvoice/ubl.py +792 -0
mockeinvoice/buyer.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""The buyer: what takes an invoice in, and what it says of it afterwards.
|
|
2
|
+
|
|
3
|
+
from mockeinvoice.buyer import Buyer
|
|
4
|
+
|
|
5
|
+
buyer = Buyer()
|
|
6
|
+
held = buyer.receive(xml) # or TurnedAway, saying why
|
|
7
|
+
answer = buyer.answer(held.id, "UQ", reasons=[{"code": "REF"}], actions=["PIN"])
|
|
8
|
+
answer.xml # the Invoice Response, as sent
|
|
9
|
+
|
|
10
|
+
A document is taken in only if it is `valid`. One that is `invalid` is turned
|
|
11
|
+
away with what was found, and so is one that is `not judged`: a document this
|
|
12
|
+
package has fatal rules left to build for is not let in as though it had
|
|
13
|
+
passed them.
|
|
14
|
+
|
|
15
|
+
What the buyer says afterwards is a Peppol Invoice Response, and Peppol's
|
|
16
|
+
guide to it has rules about the order responses come in that are in no
|
|
17
|
+
Schematron file (`OP-BR111-R004`, `-R005`, `-R012`). `PROCESS` names them,
|
|
18
|
+
and `Buyer.answer` keeps to them unless it is told to `force` one through,
|
|
19
|
+
which is how to rehearse a buyer who does not.
|
|
20
|
+
|
|
21
|
+
Nothing happens by itself after a document is received. Each later status is
|
|
22
|
+
asked for.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime
|
|
27
|
+
import threading
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
30
|
+
|
|
31
|
+
from . import response as _response
|
|
32
|
+
from . import specification as _specification
|
|
33
|
+
from .model import Document, Finding, Refused
|
|
34
|
+
from .response import Answered, Party, Response, Status
|
|
35
|
+
from .rules import Report, check, check_response
|
|
36
|
+
from .rules.peppol import PROFILES
|
|
37
|
+
from .ubl import parse_tree
|
|
38
|
+
|
|
39
|
+
GUIDE = ("https://docs.peppol.eu/poacc/upgrade-3/profiles/63-invoiceresponse/"
|
|
40
|
+
"#invoice-response-process-rules")
|
|
41
|
+
# The order a status advances in. Any may be first and any may be left out.
|
|
42
|
+
ORDER = ("AB", "IP", "UQ", "CA", "RE", "AP", "PD")
|
|
43
|
+
# The guide's rules about that order, each in our words.
|
|
44
|
+
PROCESS = {
|
|
45
|
+
"OP-BR111-R004": "nothing follows a rejection (RE) or a payment in full (PD)",
|
|
46
|
+
"OP-BR111-R005": "only paid (PD) follows accepted (AP)",
|
|
47
|
+
"OP-BR111-R012": "a status does not go back in the order %s, and only under query (UQ) "
|
|
48
|
+
"and a part payment come twice" % ", ".join(ORDER),
|
|
49
|
+
}
|
|
50
|
+
# When the buyer acknowledges a document without being asked: where the
|
|
51
|
+
# document's profile makes a response a required step, whatever the profile,
|
|
52
|
+
# or not at all.
|
|
53
|
+
ANSWERS = ("required", "always", "never")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TurnedAway(Exception):
|
|
57
|
+
"""A document the buyer did not take in, and why.
|
|
58
|
+
|
|
59
|
+
`code` is the refusal's own (`NOT-XML`, `SPECIFICATION`) where the document
|
|
60
|
+
could not be read as one that is taken, and `INVALID` or `NOT-JUDGED`
|
|
61
|
+
where it was read and its `report` is why.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, code: str, reason: str, report: Optional[Report] = None):
|
|
65
|
+
super().__init__(reason)
|
|
66
|
+
self.code, self.reason, self.report = code, reason, report
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NotSaid(Exception):
|
|
70
|
+
"""A response the buyer will not give. `code` is the guide's rule where
|
|
71
|
+
one stands in the way, and `findings` what the response's own rules found
|
|
72
|
+
where they do."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, code: str, reason: str, findings: Sequence[Finding] = ()):
|
|
75
|
+
super().__init__(reason)
|
|
76
|
+
self.code, self.reason, self.findings = code, reason, list(findings)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Sent:
|
|
81
|
+
"""One Invoice Response the buyer gave."""
|
|
82
|
+
id: str
|
|
83
|
+
invoice: str # the id of the document it answers
|
|
84
|
+
code: str
|
|
85
|
+
partial: bool # a payment in part: PD with the reason PPD
|
|
86
|
+
forced: bool # given against the guide's order
|
|
87
|
+
xml: bytes
|
|
88
|
+
# What the response's own rules had to say that did not stop it.
|
|
89
|
+
warnings: List[Finding] = field(default_factory=list)
|
|
90
|
+
# What became of sending it to the seller: {"to": url, "status": 200},
|
|
91
|
+
# {"to": url, "error": "..."}, or None where there was nowhere to send it.
|
|
92
|
+
delivery: Optional[dict] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Held:
|
|
97
|
+
"""A document the buyer took in."""
|
|
98
|
+
id: str
|
|
99
|
+
xml: bytes
|
|
100
|
+
document: Document
|
|
101
|
+
report: Report
|
|
102
|
+
received: str # when, as an ISO 8601 date and time
|
|
103
|
+
responses: List[Sent] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def number(self) -> str:
|
|
107
|
+
return self.document.text("BT-1")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def profile(self) -> str:
|
|
111
|
+
"""Peppol's billing profile, `01` or `02`; "" for XRechnung."""
|
|
112
|
+
if self.report.specification != "peppol":
|
|
113
|
+
return ""
|
|
114
|
+
return PROFILES.get(self.document.text("BT-23").strip(), "")
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def status(self) -> str:
|
|
118
|
+
"""The last thing said of it, or "" if nothing has been."""
|
|
119
|
+
return self.responses[-1].code if self.responses else ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
Clarification = Union[str, dict]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def clarifications(given: Sequence[Clarification], list_id: str) -> List[Status]:
|
|
126
|
+
"""Reasons or actions as `Status`es: a bare code, or a dict with any of
|
|
127
|
+
`code`, `text` and `conditions` (pairs of a business term and a value)."""
|
|
128
|
+
made = []
|
|
129
|
+
for one in given:
|
|
130
|
+
if isinstance(one, str):
|
|
131
|
+
one = {"code": one}
|
|
132
|
+
unknown = sorted(set(one) - {"code", "text", "conditions"})
|
|
133
|
+
if unknown:
|
|
134
|
+
raise NotSaid("REQUEST", "a clarification has code, text and conditions, "
|
|
135
|
+
"not %s" % ", ".join(unknown))
|
|
136
|
+
made.append(Status(reason_code=one.get("code", ""),
|
|
137
|
+
list_id=list_id if one.get("code") else "",
|
|
138
|
+
reason=one.get("text", ""),
|
|
139
|
+
conditions=[(str(term), str(value))
|
|
140
|
+
for term, value in one.get("conditions", ())]))
|
|
141
|
+
return made
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def out_of_order(before: Sequence[Sent], code: str, partial: bool) -> Optional[str]:
|
|
145
|
+
"""The guide's rule that a response would break after those before it, or
|
|
146
|
+
None. A code that is not one of the seven is left to the response's own
|
|
147
|
+
rules, which name it."""
|
|
148
|
+
if not before or code not in ORDER:
|
|
149
|
+
return None
|
|
150
|
+
if any(said.code == "RE" or (said.code == "PD" and not said.partial) for said in before):
|
|
151
|
+
return "OP-BR111-R004"
|
|
152
|
+
known = [said for said in before if said.code in ORDER]
|
|
153
|
+
if not known:
|
|
154
|
+
return None
|
|
155
|
+
furthest = max(known, key=lambda said: ORDER.index(said.code))
|
|
156
|
+
if furthest.code == "AP" and code != "PD":
|
|
157
|
+
return "OP-BR111-R005"
|
|
158
|
+
was, now = ORDER.index(furthest.code), ORDER.index(code)
|
|
159
|
+
if now < was or (now == was and code not in ("UQ", "PD")):
|
|
160
|
+
return "OP-BR111-R012"
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Buyer:
|
|
165
|
+
"""A buyer's system: the documents it holds and what it has said of them.
|
|
166
|
+
|
|
167
|
+
`deliver` is called with each response's XML and returns what became of
|
|
168
|
+
sending it (see `Sent.delivery`); without one, responses are kept and not
|
|
169
|
+
sent. `now` is the clock.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, answers: str = "required",
|
|
173
|
+
deliver: Optional[Callable[[bytes], dict]] = None,
|
|
174
|
+
now: Optional[Callable[[], datetime.datetime]] = None):
|
|
175
|
+
if answers not in ANSWERS:
|
|
176
|
+
raise ValueError("answers is one of %s, not %r" % (", ".join(ANSWERS), answers))
|
|
177
|
+
self.answers = answers
|
|
178
|
+
self.deliver = deliver
|
|
179
|
+
self.now = now or (lambda: datetime.datetime.now(datetime.timezone.utc))
|
|
180
|
+
self.lock = threading.RLock()
|
|
181
|
+
self.reset()
|
|
182
|
+
|
|
183
|
+
def reset(self) -> None:
|
|
184
|
+
with self.lock:
|
|
185
|
+
self.invoices: Dict[str, Held] = {}
|
|
186
|
+
self.responses: Dict[str, Sent] = {}
|
|
187
|
+
# What was turned away: (when, code, reason), oldest first.
|
|
188
|
+
self.turned_away: List[Tuple[str, str, str]] = []
|
|
189
|
+
|
|
190
|
+
def stamp(self) -> str:
|
|
191
|
+
return self.now().replace(microsecond=0, tzinfo=None).isoformat()
|
|
192
|
+
|
|
193
|
+
# -- taking a document in -----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def receive(self, data: Union[bytes, str]) -> Held:
|
|
196
|
+
"""Take a document in, or raise `TurnedAway`."""
|
|
197
|
+
raw = data.encode("utf-8") if isinstance(data, str) else data
|
|
198
|
+
with self.lock:
|
|
199
|
+
try:
|
|
200
|
+
held = self.judged(raw)
|
|
201
|
+
except TurnedAway as away:
|
|
202
|
+
self.turned_away.append((self.stamp(), away.code, away.reason))
|
|
203
|
+
raise
|
|
204
|
+
self.invoices[held.id] = held
|
|
205
|
+
if held.report.specification == "peppol" and (
|
|
206
|
+
self.answers == "always"
|
|
207
|
+
or (self.answers == "required" and held.profile == "02")):
|
|
208
|
+
self.answer(held.id, "AB")
|
|
209
|
+
return held
|
|
210
|
+
|
|
211
|
+
def judged(self, raw: bytes) -> Held:
|
|
212
|
+
try:
|
|
213
|
+
document, findings, sent = parse_tree(raw)
|
|
214
|
+
specification = _specification.identify(document)
|
|
215
|
+
except Refused as refused:
|
|
216
|
+
raise TurnedAway(refused.code, refused.reason)
|
|
217
|
+
report = check(document, specification, findings, sent)
|
|
218
|
+
if report.verdict == "invalid":
|
|
219
|
+
failures = report.failures
|
|
220
|
+
raise TurnedAway("INVALID", "the document is invalid: %d finding%s, the first %s "
|
|
221
|
+
"at %s" % (len(failures), "" if len(failures) == 1 else "s",
|
|
222
|
+
failures[0].code, failures[0].path), report)
|
|
223
|
+
if report.verdict != "valid":
|
|
224
|
+
raise TurnedAway("NOT-JUDGED", "nothing was found wrong with the document, but "
|
|
225
|
+
"%d fatal rule%s that could apply to it %s not built, so it was "
|
|
226
|
+
"not judged and is not taken in: %s"
|
|
227
|
+
% (report.unasked, "" if report.unasked == 1 else "s",
|
|
228
|
+
"is" if report.unasked == 1 else "are",
|
|
229
|
+
", ".join(not_run(report))), report)
|
|
230
|
+
return Held(str(len(self.invoices) + 1), raw, document, report, self.stamp())
|
|
231
|
+
|
|
232
|
+
# -- answering it -------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def answer(self, invoice: str, code: str, reasons: Sequence[Clarification] = (),
|
|
235
|
+
actions: Sequence[Clarification] = (), note: str = "",
|
|
236
|
+
force: bool = False) -> Sent:
|
|
237
|
+
"""Say something of a document held, or raise `NotSaid`.
|
|
238
|
+
|
|
239
|
+
The response is written, held to the Invoice Response's rules and to
|
|
240
|
+
the guide's order, kept, and sent to the seller if there is somewhere
|
|
241
|
+
to send it. `force` gives a response the guide's order forbids; it
|
|
242
|
+
does not give one the response's own rules fail.
|
|
243
|
+
"""
|
|
244
|
+
with self.lock:
|
|
245
|
+
held = self.invoices.get(invoice)
|
|
246
|
+
if held is None:
|
|
247
|
+
raise NotSaid("NO-SUCH-INVOICE", "no document %r is held" % invoice)
|
|
248
|
+
if held.report.specification != "peppol":
|
|
249
|
+
raise NotSaid("NO-RESPONSE", "document %s is %s, which has no response "
|
|
250
|
+
"message: it got its verdict and gets nothing after it"
|
|
251
|
+
% (invoice, _specification.NAMES[held.report.specification]))
|
|
252
|
+
statuses = (clarifications(reasons, _response.REASON_LIST)
|
|
253
|
+
+ clarifications(actions, _response.ACTION_LIST))
|
|
254
|
+
partial = code == "PD" and any(
|
|
255
|
+
status.reason_code == "PPD" and status.list_id == _response.REASON_LIST
|
|
256
|
+
for status in statuses)
|
|
257
|
+
broken = out_of_order(held.responses, code, partial)
|
|
258
|
+
if broken and not force:
|
|
259
|
+
raise NotSaid(broken, "%s cannot follow %s: %s (%s). \"force\" sends it anyway"
|
|
260
|
+
% (code, ", ".join(said.code for said in held.responses),
|
|
261
|
+
PROCESS[broken], broken))
|
|
262
|
+
identifier = str(len(self.responses) + 1)
|
|
263
|
+
xml = _response.write(self.written(held, identifier, code, statuses, note))
|
|
264
|
+
_read, findings, sent = _response.parse_tree(xml)
|
|
265
|
+
report = check_response(sent, findings)
|
|
266
|
+
failures = report.failures
|
|
267
|
+
if failures:
|
|
268
|
+
raise NotSaid("INVALID", "the response would fail its own rules: %s"
|
|
269
|
+
% ", ".join(sorted({f.code for f in failures})), failures)
|
|
270
|
+
said = Sent(identifier, invoice, code, partial, bool(broken), xml,
|
|
271
|
+
list(report.findings))
|
|
272
|
+
self.responses[identifier] = said
|
|
273
|
+
held.responses.append(said)
|
|
274
|
+
if self.deliver is not None:
|
|
275
|
+
said.delivery = self.deliver(xml)
|
|
276
|
+
return said
|
|
277
|
+
|
|
278
|
+
def written(self, held: Held, identifier: str, code: str, statuses: List[Status],
|
|
279
|
+
note: str) -> Response:
|
|
280
|
+
"""The response to one document: from its buyer to its seller, about
|
|
281
|
+
it by number, date and type, under the profile its own calls for."""
|
|
282
|
+
document, now = held.document, self.now()
|
|
283
|
+
return Response(
|
|
284
|
+
profile=(_response.BILLING_WITH_RESPONSE if held.profile == "02"
|
|
285
|
+
else _response.PROFILE),
|
|
286
|
+
id="IR-%s" % identifier.rjust(6, "0"),
|
|
287
|
+
issue_date=now.date().isoformat(), issue_time=now.strftime("%H:%M:%S"),
|
|
288
|
+
note=note,
|
|
289
|
+
sender=Party(endpoint=document.term("BT-49"), identifier=document.term("BT-46"),
|
|
290
|
+
name=document.text("BT-44")),
|
|
291
|
+
receiver=Party(endpoint=document.term("BT-34"), identifier=document.term("BT-29"),
|
|
292
|
+
name=document.text("BT-27")),
|
|
293
|
+
code=code, effective_date=now.date().isoformat(), statuses=statuses,
|
|
294
|
+
document=Answered(id=document.text("BT-1"), issue_date=document.text("BT-2"),
|
|
295
|
+
type_code=document.text("BT-3")))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def not_run(report: Report) -> List[str]:
|
|
299
|
+
"""The fatal rules that could apply to a document and are not built."""
|
|
300
|
+
return sorted(identifier for rules in report.not_built.values()
|
|
301
|
+
for identifier, flag in rules.items() if flag == "fatal")
|