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.
Files changed (85) hide show
  1. {trustrender-0.3.0/src/trustrender.egg-info → trustrender-0.3.1}/PKG-INFO +18 -1
  2. {trustrender-0.3.0 → trustrender-0.3.1}/README.md +17 -0
  3. {trustrender-0.3.0 → trustrender-0.3.1}/pyproject.toml +1 -1
  4. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/__init__.py +1 -1
  5. trustrender-0.3.1/src/trustrender/adapters/__init__.py +14 -0
  6. trustrender-0.3.1/src/trustrender/adapters/stripe.py +163 -0
  7. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/cli.py +7 -0
  8. {trustrender-0.3.0 → trustrender-0.3.1/src/trustrender.egg-info}/PKG-INFO +18 -1
  9. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/SOURCES.txt +3 -0
  10. trustrender-0.3.1/tests/test_adapters.py +170 -0
  11. {trustrender-0.3.0 → trustrender-0.3.1}/LICENSE +0 -0
  12. {trustrender-0.3.0 → trustrender-0.3.1}/setup.cfg +0 -0
  13. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/einvoice.j2.typ +0 -0
  14. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/einvoice_data.json +0 -0
  15. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/invoice.j2.typ +0 -0
  16. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/invoice_data.json +0 -0
  17. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/letter.j2.typ +0 -0
  18. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/letter_data.json +0 -0
  19. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/receipt.j2.typ +0 -0
  20. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/receipt_data.json +0 -0
  21. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/report.j2.typ +0 -0
  22. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/report_data.json +0 -0
  23. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/statement.j2.typ +0 -0
  24. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/builtin_templates/statement_data.json +0 -0
  25. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/contract.py +0 -0
  26. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/dashboard.py +0 -0
  27. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/doctor.py +0 -0
  28. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/engine.py +0 -0
  29. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/errors.py +0 -0
  30. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/filters.py +0 -0
  31. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fingerprint.py +0 -0
  32. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Bold.ttf +0 -0
  33. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-BoldItalic.ttf +0 -0
  34. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Italic.ttf +0 -0
  35. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/Inter-Regular.ttf +0 -0
  36. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/fonts/Inter/LICENSE.txt +0 -0
  37. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_aliases.py +0 -0
  38. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_ingest.py +0 -0
  39. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/invoice_schema.py +0 -0
  40. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/index-Bsf6YnD1.css +0 -0
  41. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/index-C6K0JsLw.js +0 -0
  42. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/assets/pdf.worker-C1breYqn.mjs +0 -0
  43. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/favicon.svg +0 -0
  44. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/icons.svg +0 -0
  45. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/playground/index.html +0 -0
  46. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/provenance.py +0 -0
  47. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/readiness.py +0 -0
  48. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/regression.py +0 -0
  49. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/semantic.py +0 -0
  50. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/server.py +0 -0
  51. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/templates.py +0 -0
  52. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/trace.py +0 -0
  53. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender/zugferd.py +0 -0
  54. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/dependency_links.txt +0 -0
  55. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/entry_points.txt +0 -0
  56. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/requires.txt +0 -0
  57. {trustrender-0.3.0 → trustrender-0.3.1}/src/trustrender.egg-info/top_level.txt +0 -0
  58. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_adversarial_corpus.py +0 -0
  59. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_audit_e2e.py +0 -0
  60. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_cli.py +0 -0
  61. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_contract.py +0 -0
  62. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_doctor.py +0 -0
  63. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_engine.py +0 -0
  64. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_error_pipeline.py +0 -0
  65. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_errors.py +0 -0
  66. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_filters.py +0 -0
  67. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fingerprint.py +0 -0
  68. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fingerprint_stress.py +0 -0
  69. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_fonts.py +0 -0
  70. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_invoice_ingest.py +0 -0
  71. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_pagination.py +0 -0
  72. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_provenance.py +0 -0
  73. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_readiness.py +0 -0
  74. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_real_payloads.py +0 -0
  75. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_regression.py +0 -0
  76. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_regression_stress.py +0 -0
  77. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_render.py +0 -0
  78. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_semantic.py +0 -0
  79. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_semantic_stress.py +0 -0
  80. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_server.py +0 -0
  81. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_templates.py +0 -0
  82. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_trace.py +0 -0
  83. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_ugly_data.py +0 -0
  84. {trustrender-0.3.0 → trustrender-0.3.1}/tests/test_ugly_data_pressure.py +0 -0
  85. {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.0
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trustrender"
7
- version = "0.3.0"
7
+ version = "0.3.1"
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.0"
39
+ __version__ = "0.3.1"
40
40
 
41
41
  __all__ = [
42
42
  # Core API (always available)
@@ -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.0
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