trustrender 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {trustrender-0.3.0/src/trustrender.egg-info → trustrender-0.3.2}/PKG-INFO +36 -2
- {trustrender-0.3.0 → trustrender-0.3.2}/README.md +35 -1
- {trustrender-0.3.0 → trustrender-0.3.2}/pyproject.toml +1 -1
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/__init__.py +1 -1
- trustrender-0.3.2/src/trustrender/adapters/__init__.py +15 -0
- trustrender-0.3.2/src/trustrender/adapters/shopify.py +178 -0
- trustrender-0.3.2/src/trustrender/adapters/stripe.py +172 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/cli.py +12 -0
- {trustrender-0.3.0 → trustrender-0.3.2/src/trustrender.egg-info}/PKG-INFO +36 -2
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender.egg-info/SOURCES.txt +4 -0
- trustrender-0.3.2/tests/test_adapters.py +357 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_doctor.py +1 -1
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_fingerprint.py +1 -1
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_provenance.py +1 -1
- {trustrender-0.3.0 → trustrender-0.3.2}/LICENSE +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/setup.cfg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/report.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/report_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/contract.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/dashboard.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/doctor.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/engine.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/errors.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/filters.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fingerprint.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Bold.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-BoldItalic.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Italic.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Regular.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/fonts/Inter/LICENSE.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/invoice_aliases.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/invoice_ingest.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/invoice_schema.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/assets/index-Bsf6YnD1.css +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/assets/index-C6K0JsLw.js +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/favicon.svg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/icons.svg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/playground/index.html +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/provenance.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/readiness.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/regression.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/semantic.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/server.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/templates.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/trace.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender/zugferd.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender.egg-info/dependency_links.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender.egg-info/entry_points.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender.egg-info/requires.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/src/trustrender.egg-info/top_level.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_adversarial_corpus.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_audit_e2e.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_cli.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_contract.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_engine.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_error_pipeline.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_errors.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_filters.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_fingerprint_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_fonts.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_invoice_ingest.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_pagination.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_readiness.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_real_payloads.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_regression.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_regression_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_render.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_semantic.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_semantic_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_server.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_templates.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_trace.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_ugly_data.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_ugly_data_pressure.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.2}/tests/test_zugferd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trustrender
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Validate and normalize Stripe, Shopify, and custom billing data for Factur-X/ZUGFeRD
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://trustrender.dev
|
|
@@ -44,7 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
|
|
45
45
|
Validate and normalize billing data from Stripe, Shopify, and custom systems before Factur-X/ZUGFeRD embedding.
|
|
46
46
|
|
|
47
|
-
If you're bridging a non-compliant billing platform into EU e-invoicing,
|
|
47
|
+
If you're bridging a non-compliant billing platform into EU e-invoicing, TrustRender adds a validation and normalization layer before tools like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) generate or embed compliant XML. It catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before handoff.
|
|
48
48
|
|
|
49
49
|
```
|
|
50
50
|
pip install trustrender
|
|
@@ -141,6 +141,40 @@ else:
|
|
|
141
141
|
- `normalizations`: field-level provenance (what was renamed, coerced, computed)
|
|
142
142
|
- `zugferd_ready`: bool (if `zugferd=True`)
|
|
143
143
|
|
|
144
|
+
## Stripe adapter
|
|
145
|
+
|
|
146
|
+
Raw Stripe Invoice API responses use cents, Unix timestamps, and nested structures. The adapter handles all of it:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
trustrender validate stripe_invoice.json --source stripe --zugferd
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from trustrender import validate_invoice
|
|
154
|
+
from trustrender.adapters import from_stripe
|
|
155
|
+
|
|
156
|
+
result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The adapter converts cents to dollars, timestamps to dates, extracts line items from `lines.data[]`, and maps customer fields to recipient. Seller info is not included in Stripe invoices — TrustRender will flag it if required for ZUGFeRD compliance.
|
|
160
|
+
|
|
161
|
+
## Shopify adapter
|
|
162
|
+
|
|
163
|
+
Shopify orders use decimal strings, split customer names, and a different structure from invoices:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
trustrender validate shopify_order.json --source shopify
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from trustrender import validate_invoice
|
|
171
|
+
from trustrender.adapters import from_shopify
|
|
172
|
+
|
|
173
|
+
result = validate_invoice(from_shopify(raw_shopify_order))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The adapter parses string amounts to floats, combines first_name + last_name, maps order fields to invoice structure, and preserves structured address fields. Shopify orders have no seller info or due date — TrustRender handles both correctly.
|
|
177
|
+
|
|
144
178
|
## What it normalizes
|
|
145
179
|
|
|
146
180
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Validate and normalize billing data from Stripe, Shopify, and custom systems before Factur-X/ZUGFeRD embedding.
|
|
4
4
|
|
|
5
|
-
If you're bridging a non-compliant billing platform into EU e-invoicing,
|
|
5
|
+
If you're bridging a non-compliant billing platform into EU e-invoicing, TrustRender adds a validation and normalization layer before tools like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) generate or embed compliant XML. It catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before handoff.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
pip install trustrender
|
|
@@ -99,6 +99,40 @@ else:
|
|
|
99
99
|
- `normalizations`: field-level provenance (what was renamed, coerced, computed)
|
|
100
100
|
- `zugferd_ready`: bool (if `zugferd=True`)
|
|
101
101
|
|
|
102
|
+
## Stripe adapter
|
|
103
|
+
|
|
104
|
+
Raw Stripe Invoice API responses use cents, Unix timestamps, and nested structures. The adapter handles all of it:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
trustrender validate stripe_invoice.json --source stripe --zugferd
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from trustrender import validate_invoice
|
|
112
|
+
from trustrender.adapters import from_stripe
|
|
113
|
+
|
|
114
|
+
result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The adapter converts cents to dollars, timestamps to dates, extracts line items from `lines.data[]`, and maps customer fields to recipient. Seller info is not included in Stripe invoices — TrustRender will flag it if required for ZUGFeRD compliance.
|
|
118
|
+
|
|
119
|
+
## Shopify adapter
|
|
120
|
+
|
|
121
|
+
Shopify orders use decimal strings, split customer names, and a different structure from invoices:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
trustrender validate shopify_order.json --source shopify
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from trustrender import validate_invoice
|
|
129
|
+
from trustrender.adapters import from_shopify
|
|
130
|
+
|
|
131
|
+
result = validate_invoice(from_shopify(raw_shopify_order))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The adapter parses string amounts to floats, combines first_name + last_name, maps order fields to invoice structure, and preserves structured address fields. Shopify orders have no seller info or due date — TrustRender handles both correctly.
|
|
135
|
+
|
|
102
136
|
## What it normalizes
|
|
103
137
|
|
|
104
138
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Source adapters for common billing platforms.
|
|
2
|
+
|
|
3
|
+
Convert raw API responses into the dict shape that
|
|
4
|
+
ingest_invoice() and validate_invoice() already understand.
|
|
5
|
+
|
|
6
|
+
Adapters are pure functions. No network, no filesystem,
|
|
7
|
+
no side effects. They do minimal structural transformation
|
|
8
|
+
and leave missing data missing — the validation pipeline
|
|
9
|
+
catches what's wrong.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .stripe import from_stripe
|
|
13
|
+
from .shopify import from_shopify
|
|
14
|
+
|
|
15
|
+
__all__ = ["from_stripe", "from_shopify"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Shopify Order adapter.
|
|
2
|
+
|
|
3
|
+
Converts a raw Shopify Admin API order response
|
|
4
|
+
(GET /admin/api/2024-01/orders/{id}.json) into a dict that
|
|
5
|
+
ingest_invoice() can normalize and validate.
|
|
6
|
+
|
|
7
|
+
Shopify has orders, not invoices. This adapter maps order data
|
|
8
|
+
to invoice-like structure for validation before e-invoice embedding.
|
|
9
|
+
|
|
10
|
+
Key transformations:
|
|
11
|
+
- Amounts: decimal strings ("100.00") → floats
|
|
12
|
+
- Dates: ISO 8601 strings → YYYY-MM-DD
|
|
13
|
+
- Customer name: first_name + last_name → combined name string
|
|
14
|
+
- Line items: title → description, price (per-unit string) → unit_price (float)
|
|
15
|
+
- Line totals: computed as quantity × unit_price (Shopify doesn't always include them)
|
|
16
|
+
- Billing address: structured fields preserved for ZUGFeRD handoff
|
|
17
|
+
- Seller: NOT included in Shopify orders. Adapter leaves sender empty.
|
|
18
|
+
The validation pipeline will flag missing sender data — this is correct behavior.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
from trustrender import validate_invoice
|
|
23
|
+
from trustrender.adapters import from_shopify
|
|
24
|
+
|
|
25
|
+
result = validate_invoice(from_shopify(raw_shopify_order))
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def from_shopify(raw: dict) -> dict:
|
|
32
|
+
"""Convert raw Shopify Order API response to ingest-ready dict.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
raw: The full JSON response from GET /admin/api/.../orders/{id}.json.
|
|
36
|
+
Amounts are decimal strings. Currency is uppercase.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A dict ready for ingest_invoice(). Not yet canonical,
|
|
40
|
+
not yet validated — just structurally bridged.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If raw is not a dict.
|
|
44
|
+
"""
|
|
45
|
+
if not isinstance(raw, dict):
|
|
46
|
+
raise ValueError(f"from_shopify expects a dict, got {type(raw).__name__}")
|
|
47
|
+
|
|
48
|
+
out: dict = {}
|
|
49
|
+
|
|
50
|
+
# ── Order identity ───────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
# Shopify uses "name" ("#1001") or "order_number" (1001)
|
|
53
|
+
if "name" in raw:
|
|
54
|
+
out["invoice_number"] = str(raw["name"]).lstrip("#")
|
|
55
|
+
elif "order_number" in raw:
|
|
56
|
+
out["invoice_number"] = str(raw["order_number"])
|
|
57
|
+
|
|
58
|
+
if "created_at" in raw and isinstance(raw["created_at"], str):
|
|
59
|
+
out["invoice_date"] = raw["created_at"][:10] # ISO 8601 → YYYY-MM-DD
|
|
60
|
+
|
|
61
|
+
# Shopify orders have no due_date — they are immediate payment
|
|
62
|
+
|
|
63
|
+
if "currency" in raw:
|
|
64
|
+
out["currency"] = raw["currency"]
|
|
65
|
+
|
|
66
|
+
# ── Amounts (strings → floats) ───────────────────────────────
|
|
67
|
+
|
|
68
|
+
for src, dst in [
|
|
69
|
+
("subtotal_price", "subtotal"),
|
|
70
|
+
("total_tax", "tax_amount"),
|
|
71
|
+
("total_price", "total"),
|
|
72
|
+
]:
|
|
73
|
+
val = raw.get(src)
|
|
74
|
+
if val is not None:
|
|
75
|
+
try:
|
|
76
|
+
out[dst] = float(val)
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# ── Tax rate (from top-level tax_lines if available) ─────────
|
|
81
|
+
|
|
82
|
+
tax_lines = raw.get("tax_lines")
|
|
83
|
+
if isinstance(tax_lines, list) and len(tax_lines) == 1:
|
|
84
|
+
rate = tax_lines[0].get("rate")
|
|
85
|
+
if isinstance(rate, (int, float)):
|
|
86
|
+
out["tax_rate"] = rate
|
|
87
|
+
|
|
88
|
+
# ── Customer → recipient ─────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
recipient: dict = {}
|
|
91
|
+
|
|
92
|
+
if isinstance(raw.get("customer"), dict):
|
|
93
|
+
cust = raw["customer"]
|
|
94
|
+
name_parts = []
|
|
95
|
+
if cust.get("first_name"):
|
|
96
|
+
name_parts.append(cust["first_name"])
|
|
97
|
+
if cust.get("last_name"):
|
|
98
|
+
name_parts.append(cust["last_name"])
|
|
99
|
+
if name_parts:
|
|
100
|
+
recipient["name"] = " ".join(name_parts)
|
|
101
|
+
if cust.get("email"):
|
|
102
|
+
recipient["email"] = cust["email"]
|
|
103
|
+
|
|
104
|
+
# Billing address
|
|
105
|
+
if isinstance(raw.get("billing_address"), dict):
|
|
106
|
+
addr = raw["billing_address"]
|
|
107
|
+
# Use billing_address.name as fallback for recipient name
|
|
108
|
+
if not recipient.get("name") and addr.get("name"):
|
|
109
|
+
recipient["name"] = addr["name"]
|
|
110
|
+
# Flatten to string for canonical address field
|
|
111
|
+
recipient["address"] = _flatten_address(addr)
|
|
112
|
+
# Preserve structured fields for ZUGFeRD handoff
|
|
113
|
+
if addr.get("city"):
|
|
114
|
+
recipient["city"] = addr["city"]
|
|
115
|
+
if addr.get("zip"):
|
|
116
|
+
recipient["postal_code"] = addr["zip"]
|
|
117
|
+
if addr.get("country_code"):
|
|
118
|
+
recipient["country"] = addr["country_code"]
|
|
119
|
+
|
|
120
|
+
if recipient:
|
|
121
|
+
out["recipient"] = recipient
|
|
122
|
+
|
|
123
|
+
# Seller is NOT available in Shopify order objects.
|
|
124
|
+
# The validation pipeline will flag this as blocked if sender.name is required.
|
|
125
|
+
|
|
126
|
+
# ── Line items ───────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
if isinstance(raw.get("line_items"), list):
|
|
129
|
+
items = []
|
|
130
|
+
for li in raw["line_items"]:
|
|
131
|
+
if not isinstance(li, dict):
|
|
132
|
+
continue
|
|
133
|
+
item: dict = {}
|
|
134
|
+
|
|
135
|
+
if "title" in li:
|
|
136
|
+
item["description"] = li["title"]
|
|
137
|
+
if "quantity" in li:
|
|
138
|
+
item["quantity"] = li["quantity"]
|
|
139
|
+
|
|
140
|
+
# Price is per-unit, as a string
|
|
141
|
+
if "price" in li:
|
|
142
|
+
try:
|
|
143
|
+
unit_price = float(li["price"])
|
|
144
|
+
item["unit_price"] = unit_price
|
|
145
|
+
# Compute line_total (Shopify doesn't always include it)
|
|
146
|
+
if "quantity" in li:
|
|
147
|
+
item["line_total"] = unit_price * li["quantity"]
|
|
148
|
+
except (ValueError, TypeError):
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
if item:
|
|
152
|
+
items.append(item)
|
|
153
|
+
|
|
154
|
+
if items:
|
|
155
|
+
out["items"] = items
|
|
156
|
+
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _flatten_address(addr: dict) -> str:
|
|
161
|
+
"""Flatten Shopify address object into a single string."""
|
|
162
|
+
parts = []
|
|
163
|
+
if addr.get("address1"):
|
|
164
|
+
parts.append(addr["address1"])
|
|
165
|
+
if addr.get("address2"):
|
|
166
|
+
parts.append(addr["address2"])
|
|
167
|
+
city_parts = []
|
|
168
|
+
if addr.get("city"):
|
|
169
|
+
city_parts.append(addr["city"])
|
|
170
|
+
if addr.get("province"):
|
|
171
|
+
city_parts.append(addr["province"])
|
|
172
|
+
if addr.get("zip"):
|
|
173
|
+
city_parts.append(addr["zip"])
|
|
174
|
+
if city_parts:
|
|
175
|
+
parts.append(", ".join(city_parts))
|
|
176
|
+
if addr.get("country"):
|
|
177
|
+
parts.append(addr["country"])
|
|
178
|
+
return ", ".join(parts)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Stripe Invoice adapter.
|
|
2
|
+
|
|
3
|
+
Converts a raw Stripe Invoice API response (GET /v1/invoices/:id)
|
|
4
|
+
into a dict that ingest_invoice() can normalize and validate.
|
|
5
|
+
|
|
6
|
+
Key transformations:
|
|
7
|
+
- Amounts: cents (int) → dollars (float), divide by 100
|
|
8
|
+
- Dates: Unix timestamps (int) → YYYY-MM-DD strings
|
|
9
|
+
- Line items: extract from lines.data[], pull unit_amount from nested price object
|
|
10
|
+
- Customer: Stripe's customer is the buyer → maps to recipient
|
|
11
|
+
- Currency: lowercased in Stripe → uppercased for canonical
|
|
12
|
+
- Seller: NOT included in Stripe invoice objects. Adapter leaves sender empty.
|
|
13
|
+
The validation pipeline will flag missing sender data — this is correct behavior,
|
|
14
|
+
not a bug. Users must supply seller identity separately.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from trustrender import validate_invoice
|
|
19
|
+
from trustrender.adapters import from_stripe
|
|
20
|
+
|
|
21
|
+
result = validate_invoice(from_stripe(raw_stripe_response))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def from_stripe(raw: dict) -> dict:
|
|
30
|
+
"""Convert raw Stripe Invoice API response to ingest-ready dict.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
raw: The full JSON response from GET /v1/invoices/:id.
|
|
34
|
+
Amounts must be in cents (Stripe's native format).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A dict ready for ingest_invoice(). Not yet canonical,
|
|
38
|
+
not yet validated — just structurally bridged.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If raw is not a dict.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(raw, dict):
|
|
44
|
+
raise ValueError(f"from_stripe expects a dict, got {type(raw).__name__}")
|
|
45
|
+
|
|
46
|
+
out: dict = {}
|
|
47
|
+
|
|
48
|
+
# ── Invoice identity ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
if "number" in raw:
|
|
51
|
+
out["invoice_number"] = raw["number"]
|
|
52
|
+
|
|
53
|
+
if "created" in raw and isinstance(raw["created"], (int, float)):
|
|
54
|
+
out["invoice_date"] = _unix_to_date(raw["created"])
|
|
55
|
+
|
|
56
|
+
if "due_date" in raw and isinstance(raw["due_date"], (int, float)):
|
|
57
|
+
out["due_date"] = _unix_to_date(raw["due_date"])
|
|
58
|
+
|
|
59
|
+
if "currency" in raw:
|
|
60
|
+
out["currency"] = raw["currency"].upper()
|
|
61
|
+
|
|
62
|
+
# ── Amounts (cents → dollars) ────────────────────────────────
|
|
63
|
+
|
|
64
|
+
if "subtotal" in raw and isinstance(raw["subtotal"], (int, float)):
|
|
65
|
+
out["subtotal"] = raw["subtotal"] / 100
|
|
66
|
+
|
|
67
|
+
if "tax" in raw and isinstance(raw["tax"], (int, float)):
|
|
68
|
+
out["tax_amount"] = raw["tax"] / 100
|
|
69
|
+
|
|
70
|
+
if "total" in raw and isinstance(raw["total"], (int, float)):
|
|
71
|
+
out["total"] = raw["total"] / 100
|
|
72
|
+
|
|
73
|
+
# ── Customer → recipient ─────────────────────────────────────
|
|
74
|
+
# Stripe's "customer" is the buyer. Maps to recipient, not sender.
|
|
75
|
+
|
|
76
|
+
recipient: dict = {}
|
|
77
|
+
|
|
78
|
+
if raw.get("customer_name"):
|
|
79
|
+
recipient["name"] = raw["customer_name"]
|
|
80
|
+
if raw.get("customer_email"):
|
|
81
|
+
recipient["email"] = raw["customer_email"]
|
|
82
|
+
if isinstance(raw.get("customer_address"), dict):
|
|
83
|
+
addr = raw["customer_address"]
|
|
84
|
+
# Flatten to string for the canonical address field
|
|
85
|
+
recipient["address"] = _flatten_address(addr)
|
|
86
|
+
# Preserve structured fields for ZUGFeRD XML generation
|
|
87
|
+
if addr.get("city"):
|
|
88
|
+
recipient["city"] = addr["city"]
|
|
89
|
+
if addr.get("postal_code"):
|
|
90
|
+
recipient["postal_code"] = addr["postal_code"]
|
|
91
|
+
if addr.get("country"):
|
|
92
|
+
recipient["country"] = addr["country"]
|
|
93
|
+
|
|
94
|
+
# Expanded customer object (if customer was expanded in the API call)
|
|
95
|
+
if isinstance(raw.get("customer"), dict):
|
|
96
|
+
cust = raw["customer"]
|
|
97
|
+
if not recipient.get("name") and cust.get("name"):
|
|
98
|
+
recipient["name"] = cust["name"]
|
|
99
|
+
if not recipient.get("email") and cust.get("email"):
|
|
100
|
+
recipient["email"] = cust["email"]
|
|
101
|
+
|
|
102
|
+
if recipient:
|
|
103
|
+
out["recipient"] = recipient
|
|
104
|
+
|
|
105
|
+
# Sender is NOT available in Stripe invoice objects.
|
|
106
|
+
# The validation pipeline will flag this as blocked if sender.name is required.
|
|
107
|
+
# This is intentional — users must supply seller identity separately.
|
|
108
|
+
|
|
109
|
+
# ── Line items ───────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
lines_obj = raw.get("lines")
|
|
112
|
+
if isinstance(lines_obj, dict) and isinstance(lines_obj.get("data"), list):
|
|
113
|
+
items = []
|
|
114
|
+
for line in lines_obj["data"]:
|
|
115
|
+
if not isinstance(line, dict):
|
|
116
|
+
continue
|
|
117
|
+
item: dict = {}
|
|
118
|
+
|
|
119
|
+
if "description" in line:
|
|
120
|
+
item["description"] = line["description"]
|
|
121
|
+
if "quantity" in line and line["quantity"] is not None:
|
|
122
|
+
item["quantity"] = line["quantity"]
|
|
123
|
+
|
|
124
|
+
# unit_amount lives on the nested price object
|
|
125
|
+
price_obj = line.get("price")
|
|
126
|
+
if isinstance(price_obj, dict):
|
|
127
|
+
ua = price_obj.get("unit_amount")
|
|
128
|
+
if isinstance(ua, (int, float)):
|
|
129
|
+
item["unit_price"] = ua / 100
|
|
130
|
+
|
|
131
|
+
# line-level amount (cents → dollars)
|
|
132
|
+
if "amount" in line and isinstance(line["amount"], (int, float)):
|
|
133
|
+
item["line_total"] = line["amount"] / 100
|
|
134
|
+
|
|
135
|
+
if item:
|
|
136
|
+
items.append(item)
|
|
137
|
+
|
|
138
|
+
if items:
|
|
139
|
+
out["items"] = items
|
|
140
|
+
|
|
141
|
+
# ── Metadata passthrough ─────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
if isinstance(raw.get("metadata"), dict) and raw["metadata"]:
|
|
144
|
+
out["_metadata"] = raw["metadata"]
|
|
145
|
+
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _unix_to_date(ts: int | float) -> str:
|
|
150
|
+
"""Convert Unix timestamp to YYYY-MM-DD string."""
|
|
151
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _flatten_address(addr: dict) -> str:
|
|
155
|
+
"""Flatten Stripe address object into a single string."""
|
|
156
|
+
parts = []
|
|
157
|
+
if addr.get("line1"):
|
|
158
|
+
parts.append(addr["line1"])
|
|
159
|
+
if addr.get("line2"):
|
|
160
|
+
parts.append(addr["line2"])
|
|
161
|
+
city_state = []
|
|
162
|
+
if addr.get("city"):
|
|
163
|
+
city_state.append(addr["city"])
|
|
164
|
+
if addr.get("state"):
|
|
165
|
+
city_state.append(addr["state"])
|
|
166
|
+
if addr.get("postal_code"):
|
|
167
|
+
city_state.append(addr["postal_code"])
|
|
168
|
+
if city_state:
|
|
169
|
+
parts.append(", ".join(city_state))
|
|
170
|
+
if addr.get("country"):
|
|
171
|
+
parts.append(addr["country"])
|
|
172
|
+
return ", ".join(parts)
|
|
@@ -62,6 +62,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
62
62
|
# ── Core: validation (no render deps required) ──
|
|
63
63
|
validate_cmd = sub.add_parser("validate", help="Validate invoice data before Factur-X/ZUGFeRD embedding")
|
|
64
64
|
validate_cmd.add_argument("data", help="Path to JSON invoice data (use '-' for stdin)")
|
|
65
|
+
validate_cmd.add_argument("--source", choices=["stripe", "shopify"], help="Apply source adapter before validation (e.g. --source stripe)")
|
|
65
66
|
validate_cmd.add_argument("--zugferd", action="store_true", help="Run ZUGFeRD EN 16931 readiness checks")
|
|
66
67
|
validate_cmd.add_argument("--format", choices=["text", "json"], default="text", dest="output_format", help="Output format (default: text)")
|
|
67
68
|
|
|
@@ -302,6 +303,15 @@ def _run_validate(args: argparse.Namespace) -> int:
|
|
|
302
303
|
print(f"error: file not found: {args.data}", file=sys.stderr)
|
|
303
304
|
return 1
|
|
304
305
|
|
|
306
|
+
# Apply source adapter if specified
|
|
307
|
+
if getattr(args, "source", None):
|
|
308
|
+
if args.source == "stripe":
|
|
309
|
+
from .adapters.stripe import from_stripe
|
|
310
|
+
raw = from_stripe(raw)
|
|
311
|
+
elif args.source == "shopify":
|
|
312
|
+
from .adapters.shopify import from_shopify
|
|
313
|
+
raw = from_shopify(raw)
|
|
314
|
+
|
|
305
315
|
result = validate_invoice(raw, zugferd=args.zugferd)
|
|
306
316
|
|
|
307
317
|
if args.output_format == "json":
|
|
@@ -877,6 +887,8 @@ def _run_audit(args: argparse.Namespace) -> int:
|
|
|
877
887
|
|
|
878
888
|
def _run_baseline(args: argparse.Namespace) -> int:
|
|
879
889
|
"""Manage render baselines."""
|
|
890
|
+
from . import audit
|
|
891
|
+
|
|
880
892
|
import json as json_mod
|
|
881
893
|
|
|
882
894
|
if args.baseline_action is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trustrender
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Validate and normalize Stripe, Shopify, and custom billing data for Factur-X/ZUGFeRD
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://trustrender.dev
|
|
@@ -44,7 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
|
|
45
45
|
Validate and normalize billing data from Stripe, Shopify, and custom systems before Factur-X/ZUGFeRD embedding.
|
|
46
46
|
|
|
47
|
-
If you're bridging a non-compliant billing platform into EU e-invoicing,
|
|
47
|
+
If you're bridging a non-compliant billing platform into EU e-invoicing, TrustRender adds a validation and normalization layer before tools like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) generate or embed compliant XML. It catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before handoff.
|
|
48
48
|
|
|
49
49
|
```
|
|
50
50
|
pip install trustrender
|
|
@@ -141,6 +141,40 @@ else:
|
|
|
141
141
|
- `normalizations`: field-level provenance (what was renamed, coerced, computed)
|
|
142
142
|
- `zugferd_ready`: bool (if `zugferd=True`)
|
|
143
143
|
|
|
144
|
+
## Stripe adapter
|
|
145
|
+
|
|
146
|
+
Raw Stripe Invoice API responses use cents, Unix timestamps, and nested structures. The adapter handles all of it:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
trustrender validate stripe_invoice.json --source stripe --zugferd
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from trustrender import validate_invoice
|
|
154
|
+
from trustrender.adapters import from_stripe
|
|
155
|
+
|
|
156
|
+
result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The adapter converts cents to dollars, timestamps to dates, extracts line items from `lines.data[]`, and maps customer fields to recipient. Seller info is not included in Stripe invoices — TrustRender will flag it if required for ZUGFeRD compliance.
|
|
160
|
+
|
|
161
|
+
## Shopify adapter
|
|
162
|
+
|
|
163
|
+
Shopify orders use decimal strings, split customer names, and a different structure from invoices:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
trustrender validate shopify_order.json --source shopify
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from trustrender import validate_invoice
|
|
171
|
+
from trustrender.adapters import from_shopify
|
|
172
|
+
|
|
173
|
+
result = validate_invoice(from_shopify(raw_shopify_order))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The adapter parses string amounts to floats, combines first_name + last_name, maps order fields to invoice structure, and preserves structured address fields. Shopify orders have no seller info or due date — TrustRender handles both correctly.
|
|
177
|
+
|
|
144
178
|
## What it normalizes
|
|
145
179
|
|
|
146
180
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -27,6 +27,9 @@ src/trustrender.egg-info/dependency_links.txt
|
|
|
27
27
|
src/trustrender.egg-info/entry_points.txt
|
|
28
28
|
src/trustrender.egg-info/requires.txt
|
|
29
29
|
src/trustrender.egg-info/top_level.txt
|
|
30
|
+
src/trustrender/adapters/__init__.py
|
|
31
|
+
src/trustrender/adapters/shopify.py
|
|
32
|
+
src/trustrender/adapters/stripe.py
|
|
30
33
|
src/trustrender/builtin_templates/einvoice.j2.typ
|
|
31
34
|
src/trustrender/builtin_templates/einvoice_data.json
|
|
32
35
|
src/trustrender/builtin_templates/invoice.j2.typ
|
|
@@ -50,6 +53,7 @@ src/trustrender/playground/index.html
|
|
|
50
53
|
src/trustrender/playground/assets/index-Bsf6YnD1.css
|
|
51
54
|
src/trustrender/playground/assets/index-C6K0JsLw.js
|
|
52
55
|
src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs
|
|
56
|
+
tests/test_adapters.py
|
|
53
57
|
tests/test_adversarial_corpus.py
|
|
54
58
|
tests/test_audit_e2e.py
|
|
55
59
|
tests/test_cli.py
|