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.
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")