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.
Files changed (87) hide show
  1. {trustrender-0.3.1/src/trustrender.egg-info → trustrender-0.3.2}/PKG-INFO +19 -2
  2. {trustrender-0.3.1 → trustrender-0.3.2}/README.md +18 -1
  3. {trustrender-0.3.1 → trustrender-0.3.2}/pyproject.toml +1 -1
  4. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/__init__.py +1 -1
  5. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/adapters/__init__.py +2 -1
  6. trustrender-0.3.2/src/trustrender/adapters/shopify.py +178 -0
  7. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/adapters/stripe.py +10 -1
  8. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/cli.py +6 -1
  9. {trustrender-0.3.1 → trustrender-0.3.2/src/trustrender.egg-info}/PKG-INFO +19 -2
  10. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/SOURCES.txt +1 -0
  11. trustrender-0.3.2/tests/test_adapters.py +357 -0
  12. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_doctor.py +1 -1
  13. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fingerprint.py +1 -1
  14. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_provenance.py +1 -1
  15. trustrender-0.3.1/tests/test_adapters.py +0 -170
  16. {trustrender-0.3.1 → trustrender-0.3.2}/LICENSE +0 -0
  17. {trustrender-0.3.1 → trustrender-0.3.2}/setup.cfg +0 -0
  18. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice.j2.typ +0 -0
  19. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/einvoice_data.json +0 -0
  20. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice.j2.typ +0 -0
  21. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/invoice_data.json +0 -0
  22. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter.j2.typ +0 -0
  23. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/letter_data.json +0 -0
  24. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt.j2.typ +0 -0
  25. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/receipt_data.json +0 -0
  26. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/report.j2.typ +0 -0
  27. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/report_data.json +0 -0
  28. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement.j2.typ +0 -0
  29. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/builtin_templates/statement_data.json +0 -0
  30. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/contract.py +0 -0
  31. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/dashboard.py +0 -0
  32. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/doctor.py +0 -0
  33. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/engine.py +0 -0
  34. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/errors.py +0 -0
  35. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/filters.py +0 -0
  36. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fingerprint.py +0 -0
  37. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Bold.ttf +0 -0
  38. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-BoldItalic.ttf +0 -0
  39. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Italic.ttf +0 -0
  40. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/Inter-Regular.ttf +0 -0
  41. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/fonts/Inter/LICENSE.txt +0 -0
  42. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_aliases.py +0 -0
  43. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_ingest.py +0 -0
  44. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/invoice_schema.py +0 -0
  45. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/index-Bsf6YnD1.css +0 -0
  46. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/index-C6K0JsLw.js +0 -0
  47. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs +0 -0
  48. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/favicon.svg +0 -0
  49. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/icons.svg +0 -0
  50. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/playground/index.html +0 -0
  51. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/provenance.py +0 -0
  52. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/readiness.py +0 -0
  53. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/regression.py +0 -0
  54. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/semantic.py +0 -0
  55. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/server.py +0 -0
  56. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/templates.py +0 -0
  57. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/trace.py +0 -0
  58. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender/zugferd.py +0 -0
  59. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/dependency_links.txt +0 -0
  60. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/entry_points.txt +0 -0
  61. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/requires.txt +0 -0
  62. {trustrender-0.3.1 → trustrender-0.3.2}/src/trustrender.egg-info/top_level.txt +0 -0
  63. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_adversarial_corpus.py +0 -0
  64. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_audit_e2e.py +0 -0
  65. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_cli.py +0 -0
  66. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_contract.py +0 -0
  67. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_engine.py +0 -0
  68. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_error_pipeline.py +0 -0
  69. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_errors.py +0 -0
  70. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_filters.py +0 -0
  71. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fingerprint_stress.py +0 -0
  72. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_fonts.py +0 -0
  73. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_invoice_ingest.py +0 -0
  74. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_pagination.py +0 -0
  75. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_readiness.py +0 -0
  76. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_real_payloads.py +0 -0
  77. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_regression.py +0 -0
  78. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_regression_stress.py +0 -0
  79. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_render.py +0 -0
  80. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_semantic.py +0 -0
  81. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_semantic_stress.py +0 -0
  82. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_server.py +0 -0
  83. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_templates.py +0 -0
  84. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_trace.py +0 -0
  85. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_ugly_data.py +0 -0
  86. {trustrender-0.3.1 → trustrender-0.3.2}/tests/test_ugly_data_pressure.py +0 -0
  87. {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.1
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, libraries like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) assume your data is already correct. TrustRender catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before compliant XML gets embedded.
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, libraries like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) assume your data is already correct. TrustRender catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before compliant XML gets embedded.
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trustrender"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Validate and normalize Stripe, Shopify, and custom billing data for Factur-X/ZUGFeRD"
9
9
  license = "MIT"
10
10
  classifiers = [
@@ -36,7 +36,7 @@ class RenderResult:
36
36
  trace_id: str | None = None
37
37
 
38
38
 
39
- __version__ = "0.3.1"
39
+ __version__ = "0.3.2"
40
40
 
41
41
  __all__ = [
42
42
  # Core API (always available)
@@ -10,5 +10,6 @@ catches what's wrong.
10
10
  """
11
11
 
12
12
  from .stripe import from_stripe
13
+ from .shopify import from_shopify
13
14
 
14
- __all__ = ["from_stripe"]
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)
@@ -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
- recipient["address"] = _flatten_address(raw["customer_address"])
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.1
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, libraries like [factur-x](https://github.com/akretion/factur-x) and [drafthorse](https://github.com/pretix/python-drafthorse) assume your data is already correct. TrustRender catches arithmetic mismatches, field misalignment, missing required fields, and structural problems before compliant XML gets embedded.
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
@@ -31,7 +31,7 @@ class TestIndividualChecks:
31
31
  def test_trustrender_import(self):
32
32
  status, msg = check_trustrender_import()
33
33
  assert status == OK
34
- assert "0.1.0" in msg
34
+ assert "importable" in msg
35
35
 
36
36
  def test_typst_py(self):
37
37
  status, msg = check_typst_py()
@@ -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 == "0.1.0"
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 == "0.1.0"
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