trustrender 0.3.1__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.1/src/trustrender.egg-info → trustrender-0.3.2}/PKG-INFO +19 -2
- {trustrender-0.3.1 → trustrender-0.3.2}/README.md +18 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/pyproject.toml +1 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/__init__.py +1 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/adapters/__init__.py +2 -1
- trustrender-0.3.2/src/trustrender/adapters/shopify.py +178 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/adapters/stripe.py +10 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/cli.py +6 -1
- {trustrender-0.3.1 → trustrender-0.3.2/src/trustrender.egg-info}/PKG-INFO +19 -2
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/SOURCES.txt +1 -0
- trustrender-0.3.2/tests/test_adapters.py +357 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_doctor.py +1 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fingerprint.py +1 -1
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_provenance.py +1 -1
- trustrender-0.3.1/tests/test_adapters.py +0 -170
- {trustrender-0.3.1 → trustrender-0.3.2}/LICENSE +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/setup.cfg +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/report.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/report_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement.j2.typ +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement_data.json +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/contract.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/dashboard.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/doctor.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/engine.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/errors.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/filters.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fingerprint.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Bold.ttf +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-BoldItalic.ttf +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Italic.ttf +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Regular.ttf +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/LICENSE.txt +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_aliases.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_ingest.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_schema.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/index-Bsf6YnD1.css +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/index-C6K0JsLw.js +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/favicon.svg +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/icons.svg +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/index.html +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/provenance.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/readiness.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/regression.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/semantic.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/server.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/templates.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/trace.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/zugferd.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/dependency_links.txt +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/entry_points.txt +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/requires.txt +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/top_level.txt +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_adversarial_corpus.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_audit_e2e.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_cli.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_contract.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_engine.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_error_pipeline.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_errors.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_filters.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fingerprint_stress.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fonts.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_invoice_ingest.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_pagination.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_readiness.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_real_payloads.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_regression.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_regression_stress.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_render.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_semantic.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_semantic_stress.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_server.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_templates.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_trace.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_ugly_data.py +0 -0
- {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_ugly_data_pressure.py +0 -0
- {trustrender-0.3.1 → 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
|
|
@@ -158,6 +158,23 @@ result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
|
158
158
|
|
|
159
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
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
|
+
|
|
161
178
|
## What it normalizes
|
|
162
179
|
|
|
163
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
|
|
@@ -116,6 +116,23 @@ result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
|
116
116
|
|
|
117
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
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
|
+
|
|
119
136
|
## What it normalizes
|
|
120
137
|
|
|
121
138
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -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)
|
|
@@ -80,7 +80,16 @@ def from_stripe(raw: dict) -> dict:
|
|
|
80
80
|
if raw.get("customer_email"):
|
|
81
81
|
recipient["email"] = raw["customer_email"]
|
|
82
82
|
if isinstance(raw.get("customer_address"), dict):
|
|
83
|
-
|
|
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"]
|
|
84
93
|
|
|
85
94
|
# Expanded customer object (if customer was expanded in the API call)
|
|
86
95
|
if isinstance(raw.get("customer"), dict):
|
|
@@ -62,7 +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"], help="Apply source adapter before validation (e.g. --source stripe)")
|
|
65
|
+
validate_cmd.add_argument("--source", choices=["stripe", "shopify"], help="Apply source adapter before validation (e.g. --source stripe)")
|
|
66
66
|
validate_cmd.add_argument("--zugferd", action="store_true", help="Run ZUGFeRD EN 16931 readiness checks")
|
|
67
67
|
validate_cmd.add_argument("--format", choices=["text", "json"], default="text", dest="output_format", help="Output format (default: text)")
|
|
68
68
|
|
|
@@ -308,6 +308,9 @@ def _run_validate(args: argparse.Namespace) -> int:
|
|
|
308
308
|
if args.source == "stripe":
|
|
309
309
|
from .adapters.stripe import from_stripe
|
|
310
310
|
raw = from_stripe(raw)
|
|
311
|
+
elif args.source == "shopify":
|
|
312
|
+
from .adapters.shopify import from_shopify
|
|
313
|
+
raw = from_shopify(raw)
|
|
311
314
|
|
|
312
315
|
result = validate_invoice(raw, zugferd=args.zugferd)
|
|
313
316
|
|
|
@@ -884,6 +887,8 @@ def _run_audit(args: argparse.Namespace) -> int:
|
|
|
884
887
|
|
|
885
888
|
def _run_baseline(args: argparse.Namespace) -> int:
|
|
886
889
|
"""Manage render baselines."""
|
|
890
|
+
from . import audit
|
|
891
|
+
|
|
887
892
|
import json as json_mod
|
|
888
893
|
|
|
889
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
|
|
@@ -158,6 +158,23 @@ result = validate_invoice(from_stripe(raw_stripe_response), zugferd=True)
|
|
|
158
158
|
|
|
159
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
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
|
+
|
|
161
178
|
## What it normalizes
|
|
162
179
|
|
|
163
180
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -28,6 +28,7 @@ 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
30
|
src/trustrender/adapters/__init__.py
|
|
31
|
+
src/trustrender/adapters/shopify.py
|
|
31
32
|
src/trustrender/adapters/stripe.py
|
|
32
33
|
src/trustrender/builtin_templates/einvoice.j2.typ
|
|
33
34
|
src/trustrender/builtin_templates/einvoice_data.json
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Tests for source adapters (Stripe first)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from trustrender.adapters.stripe import from_stripe
|
|
11
|
+
from trustrender.adapters.shopify import from_shopify
|
|
12
|
+
from trustrender import validate_invoice
|
|
13
|
+
|
|
14
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "adapters"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── from_stripe: structural transformation ───────────────────────────
|
|
18
|
+
|
|
19
|
+
class TestStripeAdapter:
|
|
20
|
+
|
|
21
|
+
def test_invoice_number(self):
|
|
22
|
+
result = from_stripe({"number": "INV-001"})
|
|
23
|
+
assert result["invoice_number"] == "INV-001"
|
|
24
|
+
|
|
25
|
+
def test_amounts_cents_to_dollars(self):
|
|
26
|
+
result = from_stripe({"subtotal": 10000, "tax": 850, "total": 10850})
|
|
27
|
+
assert result["subtotal"] == 100.00
|
|
28
|
+
assert result["tax_amount"] == 8.50
|
|
29
|
+
assert result["total"] == 108.50
|
|
30
|
+
|
|
31
|
+
def test_unix_timestamps_to_dates(self):
|
|
32
|
+
result = from_stripe({"created": 1775779200, "due_date": 1778371200})
|
|
33
|
+
assert result["invoice_date"] == "2026-04-10"
|
|
34
|
+
assert result["due_date"] == "2026-05-10"
|
|
35
|
+
|
|
36
|
+
def test_currency_uppercased(self):
|
|
37
|
+
result = from_stripe({"currency": "usd"})
|
|
38
|
+
assert result["currency"] == "USD"
|
|
39
|
+
|
|
40
|
+
def test_customer_name_and_email(self):
|
|
41
|
+
result = from_stripe({
|
|
42
|
+
"customer_name": "Acme Corp",
|
|
43
|
+
"customer_email": "billing@acme.com",
|
|
44
|
+
})
|
|
45
|
+
assert result["recipient"]["name"] == "Acme Corp"
|
|
46
|
+
assert result["recipient"]["email"] == "billing@acme.com"
|
|
47
|
+
|
|
48
|
+
def test_customer_address_flattened_and_structured(self):
|
|
49
|
+
result = from_stripe({
|
|
50
|
+
"customer_address": {
|
|
51
|
+
"line1": "123 Main St",
|
|
52
|
+
"city": "Chicago",
|
|
53
|
+
"state": "IL",
|
|
54
|
+
"postal_code": "60601",
|
|
55
|
+
"country": "US",
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
# Flattened string for canonical address field
|
|
59
|
+
addr = result["recipient"]["address"]
|
|
60
|
+
assert "123 Main St" in addr
|
|
61
|
+
assert "Chicago" in addr
|
|
62
|
+
|
|
63
|
+
# Structured fields preserved for ZUGFeRD handoff
|
|
64
|
+
assert result["recipient"]["city"] == "Chicago"
|
|
65
|
+
assert result["recipient"]["postal_code"] == "60601"
|
|
66
|
+
assert result["recipient"]["country"] == "US"
|
|
67
|
+
|
|
68
|
+
def test_expanded_customer_object(self):
|
|
69
|
+
result = from_stripe({
|
|
70
|
+
"customer": {
|
|
71
|
+
"name": "Expanded Corp",
|
|
72
|
+
"email": "expanded@corp.com",
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
assert result["recipient"]["name"] == "Expanded Corp"
|
|
76
|
+
assert result["recipient"]["email"] == "expanded@corp.com"
|
|
77
|
+
|
|
78
|
+
def test_line_items_extracted(self):
|
|
79
|
+
result = from_stripe({
|
|
80
|
+
"lines": {
|
|
81
|
+
"data": [
|
|
82
|
+
{
|
|
83
|
+
"description": "Widget",
|
|
84
|
+
"quantity": 2,
|
|
85
|
+
"amount": 5000,
|
|
86
|
+
"price": {"unit_amount": 2500},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
assert len(result["items"]) == 1
|
|
92
|
+
item = result["items"][0]
|
|
93
|
+
assert item["description"] == "Widget"
|
|
94
|
+
assert item["quantity"] == 2
|
|
95
|
+
assert item["unit_price"] == 25.00
|
|
96
|
+
assert item["line_total"] == 50.00
|
|
97
|
+
|
|
98
|
+
def test_no_sender_in_output(self):
|
|
99
|
+
"""Stripe invoices don't include seller info. Adapter must not invent it."""
|
|
100
|
+
result = from_stripe({"number": "INV-001", "customer_name": "Buyer"})
|
|
101
|
+
assert "sender" not in result
|
|
102
|
+
|
|
103
|
+
def test_missing_fields_left_missing(self):
|
|
104
|
+
"""Adapter does not guess. Missing data stays missing."""
|
|
105
|
+
result = from_stripe({})
|
|
106
|
+
assert "invoice_number" not in result
|
|
107
|
+
assert "subtotal" not in result
|
|
108
|
+
assert "items" not in result
|
|
109
|
+
|
|
110
|
+
def test_metadata_passthrough(self):
|
|
111
|
+
result = from_stripe({"metadata": {"po_number": "PO-123"}})
|
|
112
|
+
assert result["_metadata"]["po_number"] == "PO-123"
|
|
113
|
+
|
|
114
|
+
def test_rejects_non_dict(self):
|
|
115
|
+
with pytest.raises(ValueError):
|
|
116
|
+
from_stripe("not a dict")
|
|
117
|
+
with pytest.raises(ValueError):
|
|
118
|
+
from_stripe([1, 2, 3])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Full fixture: end-to-end ─────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
class TestStripeEndToEnd:
|
|
124
|
+
|
|
125
|
+
def test_full_fixture_validates(self):
|
|
126
|
+
"""Full Stripe invoice fixture → adapter → validate → pass (except missing sender)."""
|
|
127
|
+
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
128
|
+
adapted = from_stripe(raw)
|
|
129
|
+
result = validate_invoice(adapted)
|
|
130
|
+
|
|
131
|
+
# Should block on missing sender (Stripe doesn't include seller)
|
|
132
|
+
assert result["status"] == "blocked"
|
|
133
|
+
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
134
|
+
assert "identity.sender_name" in blocked_rules
|
|
135
|
+
|
|
136
|
+
# But everything else should be correct
|
|
137
|
+
canonical = result["canonical"]
|
|
138
|
+
assert canonical["invoice_number"] == "INV-2026-0042"
|
|
139
|
+
assert canonical["recipient"]["name"] == "Momentum Ventures LLC"
|
|
140
|
+
assert canonical["total"] == pytest.approx(643.40)
|
|
141
|
+
assert canonical["currency"] == "USD"
|
|
142
|
+
assert len(canonical["items"]) == 3
|
|
143
|
+
|
|
144
|
+
def test_full_fixture_with_sender_passes(self):
|
|
145
|
+
"""Add sender to Stripe output → should pass validation."""
|
|
146
|
+
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
147
|
+
adapted = from_stripe(raw)
|
|
148
|
+
adapted["sender"] = {"name": "Buildspace Labs Inc.", "email": "billing@buildspace.so"}
|
|
149
|
+
result = validate_invoice(adapted)
|
|
150
|
+
assert result["render_ready"] is True
|
|
151
|
+
|
|
152
|
+
def test_incomplete_fixture_blocks(self):
|
|
153
|
+
"""Stripe invoice missing customer + number → blocks on multiple rules."""
|
|
154
|
+
raw = json.loads((FIXTURES / "stripe_incomplete.json").read_text())
|
|
155
|
+
adapted = from_stripe(raw)
|
|
156
|
+
result = validate_invoice(adapted)
|
|
157
|
+
assert result["status"] == "blocked"
|
|
158
|
+
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
159
|
+
assert "identity.sender_name" in blocked_rules
|
|
160
|
+
assert "identity.recipient_name" in blocked_rules
|
|
161
|
+
assert "identity.invoice_number" in blocked_rules
|
|
162
|
+
|
|
163
|
+
def test_structured_address_survives_validation(self):
|
|
164
|
+
"""Structured address fields survive through validate_invoice for ZUGFeRD handoff."""
|
|
165
|
+
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
166
|
+
adapted = from_stripe(raw)
|
|
167
|
+
adapted["sender"] = {"name": "Buildspace Labs Inc."}
|
|
168
|
+
result = validate_invoice(adapted)
|
|
169
|
+
|
|
170
|
+
# Canonical recipient.address is a string
|
|
171
|
+
recipient = result["canonical"]["recipient"]
|
|
172
|
+
assert isinstance(recipient["address"], str)
|
|
173
|
+
assert "200 Corporate Plaza" in recipient["address"]
|
|
174
|
+
|
|
175
|
+
def test_zugferd_xml_with_stripe_data(self):
|
|
176
|
+
"""from_stripe → validate → build_invoice_xml should work with full data."""
|
|
177
|
+
from trustrender.zugferd import validate_zugferd_invoice_data
|
|
178
|
+
|
|
179
|
+
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
180
|
+
adapted = from_stripe(raw)
|
|
181
|
+
|
|
182
|
+
# Supply full seller data needed for ZUGFeRD
|
|
183
|
+
adapted["sender"] = {
|
|
184
|
+
"name": "Buildspace Labs Inc.",
|
|
185
|
+
"address": "500 Tech Park Dr",
|
|
186
|
+
"city": "San Francisco",
|
|
187
|
+
"postal_code": "94105",
|
|
188
|
+
"country": "DE",
|
|
189
|
+
"vat_id": "DE123456789",
|
|
190
|
+
"email": "billing@buildspace.so",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
result = validate_invoice(adapted)
|
|
194
|
+
assert result["render_ready"] is True
|
|
195
|
+
|
|
196
|
+
# Recipient should have structured fields from adapter
|
|
197
|
+
assert adapted["recipient"]["city"] == "Chicago"
|
|
198
|
+
assert adapted["recipient"]["postal_code"] == "60601"
|
|
199
|
+
assert adapted["recipient"]["country"] == "US"
|
|
200
|
+
|
|
201
|
+
def test_amounts_are_dollars_not_cents(self):
|
|
202
|
+
"""Verify cents→dollars conversion happened correctly."""
|
|
203
|
+
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
204
|
+
adapted = from_stripe(raw)
|
|
205
|
+
|
|
206
|
+
# Raw Stripe: subtotal=59300 (cents) → adapted should be 593.00
|
|
207
|
+
assert adapted["subtotal"] == 593.00
|
|
208
|
+
assert adapted["tax_amount"] == 50.40
|
|
209
|
+
assert adapted["total"] == 643.40
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── from_shopify: structural transformation ──────────────────────────
|
|
213
|
+
|
|
214
|
+
class TestShopifyAdapter:
|
|
215
|
+
|
|
216
|
+
def test_order_number_from_name(self):
|
|
217
|
+
result = from_shopify({"name": "#1047"})
|
|
218
|
+
assert result["invoice_number"] == "1047"
|
|
219
|
+
|
|
220
|
+
def test_order_number_fallback(self):
|
|
221
|
+
result = from_shopify({"order_number": 1047})
|
|
222
|
+
assert result["invoice_number"] == "1047"
|
|
223
|
+
|
|
224
|
+
def test_amounts_strings_to_floats(self):
|
|
225
|
+
result = from_shopify({
|
|
226
|
+
"subtotal_price": "100.00",
|
|
227
|
+
"total_tax": "19.00",
|
|
228
|
+
"total_price": "119.00",
|
|
229
|
+
})
|
|
230
|
+
assert result["subtotal"] == 100.00
|
|
231
|
+
assert result["tax_amount"] == 19.00
|
|
232
|
+
assert result["total"] == 119.00
|
|
233
|
+
|
|
234
|
+
def test_date_extracted(self):
|
|
235
|
+
result = from_shopify({"created_at": "2026-04-08T14:30:00+02:00"})
|
|
236
|
+
assert result["invoice_date"] == "2026-04-08"
|
|
237
|
+
|
|
238
|
+
def test_currency_preserved(self):
|
|
239
|
+
result = from_shopify({"currency": "EUR"})
|
|
240
|
+
assert result["currency"] == "EUR"
|
|
241
|
+
|
|
242
|
+
def test_customer_name_combined(self):
|
|
243
|
+
result = from_shopify({
|
|
244
|
+
"customer": {"first_name": "Klaus", "last_name": "Berger"},
|
|
245
|
+
})
|
|
246
|
+
assert result["recipient"]["name"] == "Klaus Berger"
|
|
247
|
+
|
|
248
|
+
def test_customer_email(self):
|
|
249
|
+
result = from_shopify({
|
|
250
|
+
"customer": {"first_name": "A", "last_name": "B", "email": "a@b.com"},
|
|
251
|
+
})
|
|
252
|
+
assert result["recipient"]["email"] == "a@b.com"
|
|
253
|
+
|
|
254
|
+
def test_billing_address_flattened_and_structured(self):
|
|
255
|
+
result = from_shopify({
|
|
256
|
+
"billing_address": {
|
|
257
|
+
"address1": "Werkstr. 15",
|
|
258
|
+
"city": "Düsseldorf",
|
|
259
|
+
"province": "NRW",
|
|
260
|
+
"zip": "40210",
|
|
261
|
+
"country": "Germany",
|
|
262
|
+
"country_code": "DE",
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
assert "Werkstr. 15" in result["recipient"]["address"]
|
|
266
|
+
assert result["recipient"]["city"] == "Düsseldorf"
|
|
267
|
+
assert result["recipient"]["postal_code"] == "40210"
|
|
268
|
+
assert result["recipient"]["country"] == "DE"
|
|
269
|
+
|
|
270
|
+
def test_line_items_mapped(self):
|
|
271
|
+
result = from_shopify({
|
|
272
|
+
"line_items": [
|
|
273
|
+
{"title": "Widget", "quantity": 3, "price": "25.00"},
|
|
274
|
+
],
|
|
275
|
+
})
|
|
276
|
+
assert len(result["items"]) == 1
|
|
277
|
+
item = result["items"][0]
|
|
278
|
+
assert item["description"] == "Widget"
|
|
279
|
+
assert item["quantity"] == 3
|
|
280
|
+
assert item["unit_price"] == 25.00
|
|
281
|
+
assert item["line_total"] == 75.00
|
|
282
|
+
|
|
283
|
+
def test_no_sender_in_output(self):
|
|
284
|
+
result = from_shopify({"name": "#1", "customer": {"first_name": "A", "last_name": "B"}})
|
|
285
|
+
assert "sender" not in result
|
|
286
|
+
|
|
287
|
+
def test_missing_fields_left_missing(self):
|
|
288
|
+
result = from_shopify({})
|
|
289
|
+
assert "invoice_number" not in result
|
|
290
|
+
assert "subtotal" not in result
|
|
291
|
+
|
|
292
|
+
def test_tax_rate_from_tax_lines(self):
|
|
293
|
+
result = from_shopify({
|
|
294
|
+
"tax_lines": [{"title": "VAT", "price": "19.00", "rate": 0.19}],
|
|
295
|
+
})
|
|
296
|
+
assert result["tax_rate"] == 0.19
|
|
297
|
+
|
|
298
|
+
def test_rejects_non_dict(self):
|
|
299
|
+
with pytest.raises(ValueError):
|
|
300
|
+
from_shopify("not a dict")
|
|
301
|
+
|
|
302
|
+
def test_billing_address_name_fallback(self):
|
|
303
|
+
"""If customer has no name, use billing_address.name."""
|
|
304
|
+
result = from_shopify({
|
|
305
|
+
"customer": {"email": "test@test.com"},
|
|
306
|
+
"billing_address": {"name": "Klaus Berger"},
|
|
307
|
+
})
|
|
308
|
+
assert result["recipient"]["name"] == "Klaus Berger"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ── Shopify end-to-end ───────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
class TestShopifyEndToEnd:
|
|
314
|
+
|
|
315
|
+
def test_full_fixture_validates(self):
|
|
316
|
+
raw = json.loads((FIXTURES / "shopify_order.json").read_text())
|
|
317
|
+
adapted = from_shopify(raw)
|
|
318
|
+
result = validate_invoice(adapted)
|
|
319
|
+
|
|
320
|
+
# Blocks on missing sender
|
|
321
|
+
assert result["status"] == "blocked"
|
|
322
|
+
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
323
|
+
assert "identity.sender_name" in blocked_rules
|
|
324
|
+
|
|
325
|
+
# But data is correct
|
|
326
|
+
canonical = result["canonical"]
|
|
327
|
+
assert canonical["invoice_number"] == "1047"
|
|
328
|
+
assert canonical["recipient"]["name"] == "Klaus Berger"
|
|
329
|
+
assert canonical["total"] == pytest.approx(1309.00)
|
|
330
|
+
assert canonical["currency"] == "EUR"
|
|
331
|
+
assert len(canonical["items"]) == 3
|
|
332
|
+
|
|
333
|
+
def test_full_fixture_with_sender_passes(self):
|
|
334
|
+
raw = json.loads((FIXTURES / "shopify_order.json").read_text())
|
|
335
|
+
adapted = from_shopify(raw)
|
|
336
|
+
adapted["sender"] = {"name": "Werkzeug-Shop GmbH", "email": "shop@werkzeug.de"}
|
|
337
|
+
result = validate_invoice(adapted)
|
|
338
|
+
assert result["render_ready"] is True
|
|
339
|
+
|
|
340
|
+
def test_incomplete_fixture_blocks(self):
|
|
341
|
+
raw = json.loads((FIXTURES / "shopify_incomplete.json").read_text())
|
|
342
|
+
adapted = from_shopify(raw)
|
|
343
|
+
result = validate_invoice(adapted)
|
|
344
|
+
assert result["status"] == "blocked"
|
|
345
|
+
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
346
|
+
assert "identity.sender_name" in blocked_rules
|
|
347
|
+
assert "identity.recipient_name" in blocked_rules
|
|
348
|
+
|
|
349
|
+
def test_amounts_are_floats_not_strings(self):
|
|
350
|
+
raw = json.loads((FIXTURES / "shopify_order.json").read_text())
|
|
351
|
+
adapted = from_shopify(raw)
|
|
352
|
+
assert isinstance(adapted["subtotal"], float)
|
|
353
|
+
assert adapted["subtotal"] == 1100.00
|
|
354
|
+
assert adapted["total"] == 1309.00
|
|
355
|
+
# First item: 5 × 120.00 = 600.00
|
|
356
|
+
assert adapted["items"][0]["line_total"] == 600.00
|
|
357
|
+
assert adapted["items"][0]["unit_price"] == 120.00
|
|
@@ -33,7 +33,7 @@ class TestComputeFingerprint:
|
|
|
33
33
|
assert fp.template_hash.sha256.startswith("sha256:")
|
|
34
34
|
assert fp.data_hash.startswith("sha256:")
|
|
35
35
|
assert fp.fingerprint.startswith("sha256:")
|
|
36
|
-
assert fp.trustrender_version
|
|
36
|
+
assert fp.trustrender_version # version string present
|
|
37
37
|
assert fp.created_at # ISO timestamp present
|
|
38
38
|
|
|
39
39
|
def test_deterministic(self):
|
|
@@ -26,7 +26,7 @@ class TestCreateProvenance:
|
|
|
26
26
|
def test_creates_record(self):
|
|
27
27
|
record = create_provenance(EXAMPLES / "invoice.j2.typ", _load_invoice_data())
|
|
28
28
|
assert record.engine == "trustrender"
|
|
29
|
-
assert record.engine_version
|
|
29
|
+
assert record.engine_version # version string present
|
|
30
30
|
assert record.template_name == "invoice.j2.typ"
|
|
31
31
|
assert record.template_hash.startswith("sha256:")
|
|
32
32
|
assert record.data_hash.startswith("sha256:")
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
"""Tests for source adapters (Stripe first)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
from trustrender.adapters.stripe import from_stripe
|
|
11
|
-
from trustrender import validate_invoice
|
|
12
|
-
|
|
13
|
-
FIXTURES = Path(__file__).parent / "fixtures" / "adapters"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# ── from_stripe: structural transformation ───────────────────────────
|
|
17
|
-
|
|
18
|
-
class TestStripeAdapter:
|
|
19
|
-
|
|
20
|
-
def test_invoice_number(self):
|
|
21
|
-
result = from_stripe({"number": "INV-001"})
|
|
22
|
-
assert result["invoice_number"] == "INV-001"
|
|
23
|
-
|
|
24
|
-
def test_amounts_cents_to_dollars(self):
|
|
25
|
-
result = from_stripe({"subtotal": 10000, "tax": 850, "total": 10850})
|
|
26
|
-
assert result["subtotal"] == 100.00
|
|
27
|
-
assert result["tax_amount"] == 8.50
|
|
28
|
-
assert result["total"] == 108.50
|
|
29
|
-
|
|
30
|
-
def test_unix_timestamps_to_dates(self):
|
|
31
|
-
result = from_stripe({"created": 1775779200, "due_date": 1778371200})
|
|
32
|
-
assert result["invoice_date"] == "2026-04-10"
|
|
33
|
-
assert result["due_date"] == "2026-05-10"
|
|
34
|
-
|
|
35
|
-
def test_currency_uppercased(self):
|
|
36
|
-
result = from_stripe({"currency": "usd"})
|
|
37
|
-
assert result["currency"] == "USD"
|
|
38
|
-
|
|
39
|
-
def test_customer_name_and_email(self):
|
|
40
|
-
result = from_stripe({
|
|
41
|
-
"customer_name": "Acme Corp",
|
|
42
|
-
"customer_email": "billing@acme.com",
|
|
43
|
-
})
|
|
44
|
-
assert result["recipient"]["name"] == "Acme Corp"
|
|
45
|
-
assert result["recipient"]["email"] == "billing@acme.com"
|
|
46
|
-
|
|
47
|
-
def test_customer_address_flattened(self):
|
|
48
|
-
result = from_stripe({
|
|
49
|
-
"customer_address": {
|
|
50
|
-
"line1": "123 Main St",
|
|
51
|
-
"city": "Chicago",
|
|
52
|
-
"state": "IL",
|
|
53
|
-
"postal_code": "60601",
|
|
54
|
-
"country": "US",
|
|
55
|
-
},
|
|
56
|
-
})
|
|
57
|
-
addr = result["recipient"]["address"]
|
|
58
|
-
assert "123 Main St" in addr
|
|
59
|
-
assert "Chicago" in addr
|
|
60
|
-
assert "IL" in addr
|
|
61
|
-
|
|
62
|
-
def test_expanded_customer_object(self):
|
|
63
|
-
result = from_stripe({
|
|
64
|
-
"customer": {
|
|
65
|
-
"name": "Expanded Corp",
|
|
66
|
-
"email": "expanded@corp.com",
|
|
67
|
-
},
|
|
68
|
-
})
|
|
69
|
-
assert result["recipient"]["name"] == "Expanded Corp"
|
|
70
|
-
assert result["recipient"]["email"] == "expanded@corp.com"
|
|
71
|
-
|
|
72
|
-
def test_line_items_extracted(self):
|
|
73
|
-
result = from_stripe({
|
|
74
|
-
"lines": {
|
|
75
|
-
"data": [
|
|
76
|
-
{
|
|
77
|
-
"description": "Widget",
|
|
78
|
-
"quantity": 2,
|
|
79
|
-
"amount": 5000,
|
|
80
|
-
"price": {"unit_amount": 2500},
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
},
|
|
84
|
-
})
|
|
85
|
-
assert len(result["items"]) == 1
|
|
86
|
-
item = result["items"][0]
|
|
87
|
-
assert item["description"] == "Widget"
|
|
88
|
-
assert item["quantity"] == 2
|
|
89
|
-
assert item["unit_price"] == 25.00
|
|
90
|
-
assert item["line_total"] == 50.00
|
|
91
|
-
|
|
92
|
-
def test_no_sender_in_output(self):
|
|
93
|
-
"""Stripe invoices don't include seller info. Adapter must not invent it."""
|
|
94
|
-
result = from_stripe({"number": "INV-001", "customer_name": "Buyer"})
|
|
95
|
-
assert "sender" not in result
|
|
96
|
-
|
|
97
|
-
def test_missing_fields_left_missing(self):
|
|
98
|
-
"""Adapter does not guess. Missing data stays missing."""
|
|
99
|
-
result = from_stripe({})
|
|
100
|
-
assert "invoice_number" not in result
|
|
101
|
-
assert "subtotal" not in result
|
|
102
|
-
assert "items" not in result
|
|
103
|
-
|
|
104
|
-
def test_metadata_passthrough(self):
|
|
105
|
-
result = from_stripe({"metadata": {"po_number": "PO-123"}})
|
|
106
|
-
assert result["_metadata"]["po_number"] == "PO-123"
|
|
107
|
-
|
|
108
|
-
def test_rejects_non_dict(self):
|
|
109
|
-
with pytest.raises(ValueError):
|
|
110
|
-
from_stripe("not a dict")
|
|
111
|
-
with pytest.raises(ValueError):
|
|
112
|
-
from_stripe([1, 2, 3])
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# ── Full fixture: end-to-end ─────────────────────────────────────────
|
|
116
|
-
|
|
117
|
-
class TestStripeEndToEnd:
|
|
118
|
-
|
|
119
|
-
def test_full_fixture_validates(self):
|
|
120
|
-
"""Full Stripe invoice fixture → adapter → validate → pass (except missing sender)."""
|
|
121
|
-
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
122
|
-
adapted = from_stripe(raw)
|
|
123
|
-
result = validate_invoice(adapted)
|
|
124
|
-
|
|
125
|
-
# Should block on missing sender (Stripe doesn't include seller)
|
|
126
|
-
assert result["status"] == "blocked"
|
|
127
|
-
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
128
|
-
assert "identity.sender_name" in blocked_rules
|
|
129
|
-
|
|
130
|
-
# But everything else should be correct
|
|
131
|
-
canonical = result["canonical"]
|
|
132
|
-
assert canonical["invoice_number"] == "INV-2026-0042"
|
|
133
|
-
assert canonical["recipient"]["name"] == "Momentum Ventures LLC"
|
|
134
|
-
assert canonical["total"] == pytest.approx(643.40)
|
|
135
|
-
assert canonical["currency"] == "USD"
|
|
136
|
-
assert len(canonical["items"]) == 3
|
|
137
|
-
|
|
138
|
-
def test_full_fixture_with_sender_passes(self):
|
|
139
|
-
"""Add sender to Stripe output → should pass validation."""
|
|
140
|
-
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
141
|
-
adapted = from_stripe(raw)
|
|
142
|
-
adapted["sender"] = {"name": "Buildspace Labs Inc.", "email": "billing@buildspace.so"}
|
|
143
|
-
result = validate_invoice(adapted)
|
|
144
|
-
assert result["render_ready"] is True
|
|
145
|
-
|
|
146
|
-
def test_incomplete_fixture_blocks(self):
|
|
147
|
-
"""Stripe invoice missing customer + number → blocks on multiple rules."""
|
|
148
|
-
raw = json.loads((FIXTURES / "stripe_incomplete.json").read_text())
|
|
149
|
-
adapted = from_stripe(raw)
|
|
150
|
-
result = validate_invoice(adapted)
|
|
151
|
-
assert result["status"] == "blocked"
|
|
152
|
-
blocked_rules = {e["rule_id"] for e in result["errors"] if e.get("severity") == "blocked"}
|
|
153
|
-
assert "identity.sender_name" in blocked_rules
|
|
154
|
-
assert "identity.recipient_name" in blocked_rules
|
|
155
|
-
assert "identity.invoice_number" in blocked_rules
|
|
156
|
-
|
|
157
|
-
def test_amounts_are_dollars_not_cents(self):
|
|
158
|
-
"""Verify cents→dollars conversion happened correctly."""
|
|
159
|
-
raw = json.loads((FIXTURES / "stripe_invoice.json").read_text())
|
|
160
|
-
adapted = from_stripe(raw)
|
|
161
|
-
|
|
162
|
-
# Raw Stripe: subtotal=59300 (cents) → adapted should be 593.00
|
|
163
|
-
assert adapted["subtotal"] == 593.00
|
|
164
|
-
assert adapted["tax_amount"] == 50.40
|
|
165
|
-
assert adapted["total"] == 643.40
|
|
166
|
-
|
|
167
|
-
# Line items also converted
|
|
168
|
-
assert adapted["items"][0]["line_total"] == 399.00
|
|
169
|
-
assert adapted["items"][0]["unit_price"] == 399.00
|
|
170
|
-
assert adapted["items"][1]["line_total"] == 45.00
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice_data.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement_data.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/index-Bsf6YnD1.css
RENAMED
|
File without changes
|
|
File without changes
|
{trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|