trustrender 0.3.0__tar.gz → 0.3.1__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.1}/PKG-INFO +18 -1
- {trustrender-0.3.0 → trustrender-0.3.1}/README.md +17 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/pyproject.toml +1 -1
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/__init__.py +1 -1
- trustrender-0.3.1/src/trustrender/adapters/__init__.py +14 -0
- trustrender-0.3.1/src/trustrender/adapters/stripe.py +163 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/cli.py +7 -0
- {trustrender-0.3.0 → trustrender-0.3.1/src/trustrender.egg-info}/PKG-INFO +18 -1
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/SOURCES.txt +3 -0
- trustrender-0.3.1/tests/test_adapters.py +170 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/LICENSE +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/setup.cfg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/einvoice.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/einvoice_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/invoice.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/invoice_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/letter.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/letter_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/receipt.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/receipt_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/report.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/report_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/statement.j2.typ +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/statement_data.json +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/contract.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/dashboard.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/doctor.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/engine.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/errors.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/filters.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fingerprint.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Bold.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-BoldItalic.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Italic.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Regular.ttf +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/LICENSE.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_aliases.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_ingest.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_schema.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/index-Bsf6YnD1.css +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/index-C6K0JsLw.js +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/favicon.svg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/icons.svg +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/index.html +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/provenance.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/readiness.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/regression.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/semantic.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/server.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/templates.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/trace.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/zugferd.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/dependency_links.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/entry_points.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/requires.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/top_level.txt +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_adversarial_corpus.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_audit_e2e.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_cli.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_contract.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_doctor.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_engine.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_error_pipeline.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_errors.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_filters.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fingerprint.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fingerprint_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fonts.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_invoice_ingest.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_pagination.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_provenance.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_readiness.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_real_payloads.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_regression.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_regression_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_render.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_semantic.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_semantic_stress.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_server.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_templates.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_trace.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_ugly_data.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_ugly_data_pressure.py +0 -0
- {trustrender-0.3.0 → trustrender-0.3.1}/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.1
|
|
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
|
|
@@ -141,6 +141,23 @@ 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
|
+
|
|
144
161
|
## What it normalizes
|
|
145
162
|
|
|
146
163
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -99,6 +99,23 @@ 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
|
+
|
|
102
119
|
## What it normalizes
|
|
103
120
|
|
|
104
121
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
14
|
+
__all__ = ["from_stripe"]
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
recipient["address"] = _flatten_address(raw["customer_address"])
|
|
84
|
+
|
|
85
|
+
# Expanded customer object (if customer was expanded in the API call)
|
|
86
|
+
if isinstance(raw.get("customer"), dict):
|
|
87
|
+
cust = raw["customer"]
|
|
88
|
+
if not recipient.get("name") and cust.get("name"):
|
|
89
|
+
recipient["name"] = cust["name"]
|
|
90
|
+
if not recipient.get("email") and cust.get("email"):
|
|
91
|
+
recipient["email"] = cust["email"]
|
|
92
|
+
|
|
93
|
+
if recipient:
|
|
94
|
+
out["recipient"] = recipient
|
|
95
|
+
|
|
96
|
+
# Sender is NOT available in Stripe invoice objects.
|
|
97
|
+
# The validation pipeline will flag this as blocked if sender.name is required.
|
|
98
|
+
# This is intentional — users must supply seller identity separately.
|
|
99
|
+
|
|
100
|
+
# ── Line items ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
lines_obj = raw.get("lines")
|
|
103
|
+
if isinstance(lines_obj, dict) and isinstance(lines_obj.get("data"), list):
|
|
104
|
+
items = []
|
|
105
|
+
for line in lines_obj["data"]:
|
|
106
|
+
if not isinstance(line, dict):
|
|
107
|
+
continue
|
|
108
|
+
item: dict = {}
|
|
109
|
+
|
|
110
|
+
if "description" in line:
|
|
111
|
+
item["description"] = line["description"]
|
|
112
|
+
if "quantity" in line and line["quantity"] is not None:
|
|
113
|
+
item["quantity"] = line["quantity"]
|
|
114
|
+
|
|
115
|
+
# unit_amount lives on the nested price object
|
|
116
|
+
price_obj = line.get("price")
|
|
117
|
+
if isinstance(price_obj, dict):
|
|
118
|
+
ua = price_obj.get("unit_amount")
|
|
119
|
+
if isinstance(ua, (int, float)):
|
|
120
|
+
item["unit_price"] = ua / 100
|
|
121
|
+
|
|
122
|
+
# line-level amount (cents → dollars)
|
|
123
|
+
if "amount" in line and isinstance(line["amount"], (int, float)):
|
|
124
|
+
item["line_total"] = line["amount"] / 100
|
|
125
|
+
|
|
126
|
+
if item:
|
|
127
|
+
items.append(item)
|
|
128
|
+
|
|
129
|
+
if items:
|
|
130
|
+
out["items"] = items
|
|
131
|
+
|
|
132
|
+
# ── Metadata passthrough ─────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
if isinstance(raw.get("metadata"), dict) and raw["metadata"]:
|
|
135
|
+
out["_metadata"] = raw["metadata"]
|
|
136
|
+
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _unix_to_date(ts: int | float) -> str:
|
|
141
|
+
"""Convert Unix timestamp to YYYY-MM-DD string."""
|
|
142
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _flatten_address(addr: dict) -> str:
|
|
146
|
+
"""Flatten Stripe address object into a single string."""
|
|
147
|
+
parts = []
|
|
148
|
+
if addr.get("line1"):
|
|
149
|
+
parts.append(addr["line1"])
|
|
150
|
+
if addr.get("line2"):
|
|
151
|
+
parts.append(addr["line2"])
|
|
152
|
+
city_state = []
|
|
153
|
+
if addr.get("city"):
|
|
154
|
+
city_state.append(addr["city"])
|
|
155
|
+
if addr.get("state"):
|
|
156
|
+
city_state.append(addr["state"])
|
|
157
|
+
if addr.get("postal_code"):
|
|
158
|
+
city_state.append(addr["postal_code"])
|
|
159
|
+
if city_state:
|
|
160
|
+
parts.append(", ".join(city_state))
|
|
161
|
+
if addr.get("country"):
|
|
162
|
+
parts.append(addr["country"])
|
|
163
|
+
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"], 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,12 @@ 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
|
+
|
|
305
312
|
result = validate_invoice(raw, zugferd=args.zugferd)
|
|
306
313
|
|
|
307
314
|
if args.output_format == "json":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trustrender
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -141,6 +141,23 @@ 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
|
+
|
|
144
161
|
## What it normalizes
|
|
145
162
|
|
|
146
163
|
90+ vendor field aliases across QuickBooks, Xero, Stripe, and generic CSV/ERP formats:
|
|
@@ -27,6 +27,8 @@ 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/stripe.py
|
|
30
32
|
src/trustrender/builtin_templates/einvoice.j2.typ
|
|
31
33
|
src/trustrender/builtin_templates/einvoice_data.json
|
|
32
34
|
src/trustrender/builtin_templates/invoice.j2.typ
|
|
@@ -50,6 +52,7 @@ src/trustrender/playground/index.html
|
|
|
50
52
|
src/trustrender/playground/assets/index-Bsf6YnD1.css
|
|
51
53
|
src/trustrender/playground/assets/index-C6K0JsLw.js
|
|
52
54
|
src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs
|
|
55
|
+
tests/test_adapters.py
|
|
53
56
|
tests/test_adversarial_corpus.py
|
|
54
57
|
tests/test_audit_e2e.py
|
|
55
58
|
tests/test_cli.py
|
|
@@ -0,0 +1,170 @@
|
|
|
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.0 → trustrender-0.3.1}/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.0 → trustrender-0.3.1}/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.0 → trustrender-0.3.1}/src/trustrender/playground/assets/index-Bsf6YnD1.css
RENAMED
|
File without changes
|
|
File without changes
|
{trustrender-0.3.0 → trustrender-0.3.1}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|