@1aboveio/skills 1.0.10 → 1.0.12

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 (214) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +298 -216
  3. package/README.md +3 -3
  4. package/package.json +10 -8
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +35 -37
  7. package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
  8. package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
  9. package/skills/app-dev/android-foundation/references/setup.md +24 -9
  10. package/skills/app-dev/android-foundation/references/workspace.md +81 -0
  11. package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  12. package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  13. package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  14. package/skills/app-dev/app-debug/SKILL.md +1 -1
  15. package/skills/app-dev/better-auth/SKILL.md +1 -1
  16. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  17. package/skills/app-dev/data-quality/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/SKILL.md +15 -10
  19. package/skills/app-dev/ios-foundation/references/setup.md +25 -9
  20. package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
  21. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  22. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  23. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +3 -3
  50. package/skills/core/distill-writing/references/extraction.md +1 -1
  51. package/skills/core/doc-to-md/SKILL.md +1 -1
  52. package/skills/core/editorial-review/SKILL.md +82 -12
  53. package/skills/core/editorial-review/references/checklist.md +26 -1
  54. package/skills/core/house-style/SKILL.md +8 -3
  55. package/skills/core/house-style/references/analytical-reporting.md +123 -0
  56. package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
  57. package/skills/core/house-style/references/analytical-writing-research.md +129 -0
  58. package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
  59. package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
  60. package/skills/core/house-style/references/document-workflow.md +193 -0
  61. package/skills/core/house-style/references/initialization.md +66 -0
  62. package/skills/core/house-style/references/persistence.md +9 -4
  63. package/skills/core/house-style/references/rules.md +11 -4
  64. package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
  65. package/skills/core/house-style/templates/analytical-report.md +54 -0
  66. package/skills/core/house-style/templates/gap-analysis.md +1 -1
  67. package/skills/core/plain-language/SKILL.md +2 -2
  68. package/skills/core/polish-cn/SKILL.md +9 -2
  69. package/skills/core/reg-parser/SKILL.md +3 -3
  70. package/skills/core/terminology/SKILL.md +8 -4
  71. package/skills/core/write-doc/SKILL.md +40 -151
  72. package/skills/core/write-report/SKILL.md +129 -0
  73. package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
  74. package/skills/engineering/better-review/SKILL.md +37 -37
  75. package/skills/engineering/better-review/references/passes.md +35 -41
  76. package/skills/engineering/better-review/references/result.md +10 -9
  77. package/skills/engineering/e2e-test/SKILL.md +17 -17
  78. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
  79. package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
  80. package/skills/engineering/e2e-test/references/reviewing.md +9 -11
  81. package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
  82. package/skills/engineering/engineering/SKILL.md +201 -0
  83. package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
  84. package/skills/engineering/engineering/references/feature-delivery.md +8 -0
  85. package/skills/engineering/engineering/references/init-scope.md +45 -0
  86. package/skills/engineering/engineering/references/scope-management.md +85 -0
  87. package/skills/engineering/engineering/scripts/init.mjs +160 -0
  88. package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
  89. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  90. package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
  91. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  92. package/skills/engineering/ensure-coverage/SKILL.md +22 -109
  93. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
  94. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
  95. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
  96. package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
  97. package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
  98. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
  99. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
  100. package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
  101. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
  102. package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
  103. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
  104. package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
  105. package/skills/engineering/golden-path/SKILL.md +1 -1
  106. package/skills/engineering/harness-runtime/SKILL.md +3 -1
  107. package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
  108. package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
  109. package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
  110. package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
  111. package/skills/engineering/resolve-issues/SKILL.md +40 -61
  112. package/skills/engineering/resolve-issues/references/ci.md +13 -3
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
  114. package/skills/engineering/resolve-issues/references/done.md +11 -2
  115. package/skills/engineering/resolve-issues/references/expire.md +9 -2
  116. package/skills/engineering/resolve-issues/references/implementation.md +33 -17
  117. package/skills/engineering/resolve-issues/references/preflight.md +3 -1
  118. package/skills/engineering/resolve-issues/references/principles.md +52 -0
  119. package/skills/engineering/resolve-issues/references/review.md +25 -15
  120. package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
  121. package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
  122. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
  123. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
  124. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
  125. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
  126. package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
  127. package/skills/engineering/resolve-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-issues/SKILL.md +7 -5
  129. package/skills/engineering/rush-release/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/SKILL.md +19 -4
  131. package/skills/engineering/test-quality/references/lint.md +1 -1
  132. package/skills/engineering/test-quality/references/principles.md +2 -2
  133. package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
  134. package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
  135. package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
  136. package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
  137. package/skills/risk-compliance/contract-review/SKILL.md +6 -1
  138. package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
  139. package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
  140. package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
  141. package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
  142. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
  143. package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
  144. package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
  145. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
  146. package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
  147. package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
  148. package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
  149. package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
  150. package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
  151. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
  152. package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
  153. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
  154. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
  155. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
  156. package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
  157. package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
  158. package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
  159. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
  160. package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
  161. package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
  162. package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
  163. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
  164. package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
  165. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
  166. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
  167. package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
  168. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
  169. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
  170. package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
  171. package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
  172. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
  173. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
  174. package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
  175. package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
  176. package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
  177. package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
  178. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
  179. package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
  180. package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
  181. package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
  182. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
  183. package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
  184. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
  185. package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
  186. package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
  187. package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
  188. package/skills/engineering/contributing/SKILL.md +0 -70
  189. package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
  190. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
  191. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
  192. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
  193. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
  194. package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
  195. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
  196. package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
  197. package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
  198. package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
  199. package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
  200. package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
  201. /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
  202. /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
  203. /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
  204. /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
  205. /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
  206. /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
  207. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
  208. /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
  209. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
  210. /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
  211. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
  212. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
  213. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
  214. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env python3
2
+ """Analyze Worldpay CSV exports without network access. Amounts use original transaction dates."""
3
+ import argparse
4
+ import calendar
5
+ import csv
6
+ import hashlib
7
+ import json
8
+ from collections import Counter, defaultdict
9
+ from datetime import date, datetime, timedelta
10
+ from decimal import Decimal, InvalidOperation
11
+ from pathlib import Path
12
+
13
+ import risk_history
14
+ from csv_export import safe_writer
15
+
16
+ LAYERS = {'Authorizations': ('auth', 'Timestamp', ('Transaction ID', 'Attempt')),
17
+ 'Sales-and-refunds': ('settlement', 'Settlement date', ('Transaction ID',)),
18
+ 'Chargebacks': ('dispute', 'Chargeback date', ('Dispute ID',))}
19
+
20
+
21
+ def sha(path):
22
+ return hashlib.sha256(path.read_bytes()).hexdigest()
23
+
24
+
25
+ def day(value):
26
+ if not value:
27
+ raise ValueError('Missing event date')
28
+ parsed = datetime.fromisoformat(value)
29
+ if parsed.tzinfo is not None:
30
+ raise ValueError('Zoned source timestamps require calendar alignment')
31
+ return parsed.date().isoformat()
32
+
33
+
34
+ def shift(d, offset):
35
+ m = d.year * 12 + d.month - 1 + offset
36
+ y, m = divmod(m, 12)
37
+ return date(y, m + 1, min(d.day, calendar.monthrange(y, m + 1)[1]))
38
+
39
+
40
+ def read_sources(root):
41
+ layers = defaultdict(list)
42
+ sources = []
43
+ for path in sorted(root.iterdir()):
44
+ if not path.is_file() or path.suffix.lower() != '.csv':
45
+ continue
46
+ match = next((v for k, v in LAYERS.items() if path.name.startswith(k + '_')), None)
47
+ if not match:
48
+ continue
49
+ layer, field, keys = match
50
+ with path.open(encoding='utf-8-sig', newline='') as f:
51
+ reader = csv.DictReader(f)
52
+ required = {field, *keys, 'Sub merchant ID', 'Billing descriptor'}
53
+ if layer == 'auth':
54
+ required.add('Response type')
55
+ elif layer == 'settlement':
56
+ required.update({'Settlement type', 'Settlement amount', 'Settlement amount currency', 'Timestamp'})
57
+ if not reader.fieldnames or not required.issubset(reader.fieldnames):
58
+ raise ValueError(f'{path.name}: missing required columns {sorted(required - set(reader.fieldnames or []))}')
59
+ rows = list(reader)
60
+ if any(None in row for row in rows):
61
+ raise ValueError(f'{path.name}: malformed CSV row')
62
+ if layer == 'auth' and any(not (row.get('Response type') or '').strip() for row in rows):
63
+ raise ValueError(f'{path.name}: empty Response type')
64
+ dates = sorted(day(r[field]) for r in rows)
65
+ sources.append({'file': path.name, 'layer': layer, 'sha256': sha(path), 'rows': len(rows),
66
+ 'min_date': dates[0] if dates else None, 'max_date': dates[-1] if dates else None})
67
+ layers[layer].extend(rows)
68
+ if not layers['auth']:
69
+ raise ValueError('Nonempty authorization CSV layer is required')
70
+ if any(not any(s['max_date'] for s in sources if s['layer'] == layer) for layer in {s['layer'] for s in sources}):
71
+ raise ValueError('Present layers must contain dated rows to establish a cutoff')
72
+ return layers, sources
73
+
74
+
75
+ def dedup_provider(value):
76
+ normalized = ' '.join((value or '').split()).casefold()
77
+ return normalized if normalized in ('worldpay - wpg', 'worldpay - acquiring') else value
78
+
79
+
80
+ def row_variant(row):
81
+ return tuple(sorted((k, dedup_provider(v) if k == 'Provider' else v) for k, v in row.items()))
82
+
83
+
84
+ def dedup(rows, keys, snapshots=False):
85
+ groups = defaultdict(list)
86
+ for row in rows:
87
+ key = tuple(dedup_provider(row.get(k, '')) if k == 'Provider' else row.get(k, '') for k in keys)
88
+ # A missing key does not establish identity. Keep every such observation.
89
+ if not all((v or '').strip() for v in key):
90
+ key = ('__missing__', str(len(groups)), str(id(row)))
91
+ groups[key].append(row)
92
+ retained, duplicate, conflicts, missing = [], 0, 0, 0
93
+ for key, group in groups.items():
94
+ variants = {row_variant(row): row for row in group}
95
+ duplicate += len(group) - len(variants)
96
+ missing += key[0] == '__missing__'
97
+ if len(variants) > 1:
98
+ conflicts += 1
99
+ if snapshots or len(variants) == 1:
100
+ retained.extend(variants.values())
101
+ # Conflicting auth/settlement variants are counted here; analyze refuses the report.
102
+ return retained, {'raw_rows': len(rows), 'exact_duplicates_removed': duplicate,
103
+ 'conflicting_identity_groups': conflicts, 'missing_identity_rows': missing,
104
+ 'retained_rows': len(retained),
105
+ 'conflicting_rows_excluded': 0 if snapshots else sum(len({row_variant(r) for r in g}) for g in groups.values() if len({row_variant(r) for r in g}) > 1),
106
+ 'identity_fields': list(keys), 'conflict_policy': 'retain distinct snapshots' if snapshots else 'refuse report on conflicts'}
107
+
108
+
109
+ def fx_load(path):
110
+ if not path:
111
+ return {}, None
112
+ data = json.loads(path.read_text(encoding='utf-8'))
113
+ if not isinstance(data, dict):
114
+ raise ValueError('FX rates must be an object: date -> currency -> USD rate')
115
+ rates = {}
116
+ for d, currencies in data.items():
117
+ date.fromisoformat(d)
118
+ for c, value in currencies.items():
119
+ n = Decimal(str(value))
120
+ if not n.is_finite() or n <= 0:
121
+ raise ValueError('FX rates must be finite positive USD-per-unit values')
122
+ rates[d, c.upper()] = n
123
+ return rates, {'file': str(path), 'sha256': sha(path), 'format': 'date -> currency -> USD per unit', 'provenance': 'caller supplied; not independently verified'}
124
+
125
+
126
+ def provider_namespace(row):
127
+ provider = ' '.join((row.get('Provider') or '').split()).casefold()
128
+ if provider in ('worldpay - wpg', 'worldpay - acquiring'):
129
+ return 'worldpay'
130
+ return provider
131
+
132
+
133
+ def counts(rows, field):
134
+ return dict(sorted(Counter(r.get(field, '') or 'Unknown' for r in rows).items()))
135
+
136
+
137
+ def amounts(rows, rates):
138
+ total, native, missing = Decimal(0), defaultdict(Decimal), Counter()
139
+ for r in rows:
140
+ try:
141
+ amount = Decimal(r['Settlement amount'])
142
+ if not amount.is_finite():
143
+ raise ValueError('Non-finite settlement amount')
144
+ except (InvalidOperation, KeyError) as e:
145
+ raise ValueError('Invalid settlement amount') from e
146
+ currency = r.get('Settlement amount currency', '').upper()
147
+ d = day(r['Timestamp'])
148
+ native[currency or 'Unknown'] += amount
149
+ rate = Decimal(1) if currency == 'USD' else rates.get((d, currency))
150
+ if rate is None:
151
+ missing[d, currency or 'Unknown'] += 1
152
+ else:
153
+ total += amount * rate
154
+ return {'usd': str(total) if not missing else None,
155
+ 'native': {k: str(v) for k, v in sorted(native.items())},
156
+ 'missing_fx': [{'date': d, 'currency': c, 'rows': n} for (d, c), n in sorted(missing.items())]}
157
+
158
+
159
+ def approved_amounts(auth, rates):
160
+ """Sum successful attempt amounts, never substitute settled sales or partial FX totals."""
161
+ if not auth:
162
+ return None
163
+ total, native, missing_fx = Decimal(0), defaultdict(Decimal), Counter()
164
+ missing_amount_rows = 0
165
+ for row in auth:
166
+ if row.get('Response type', '').lower() != 'approval':
167
+ continue
168
+ try:
169
+ value = Decimal(row.get('Transaction amount', ''))
170
+ if not value.is_finite() or value < 0:
171
+ raise InvalidOperation
172
+ except (InvalidOperation, TypeError):
173
+ missing_amount_rows += 1
174
+ continue
175
+ currency = row.get('Transaction amount currency', '').strip().upper()
176
+ rate_date = day(row['Timestamp'])
177
+ native[currency or 'Unknown'] += value
178
+ rate = Decimal(1) if currency == 'USD' else rates.get((rate_date, currency))
179
+ if rate is None:
180
+ missing_fx[rate_date, currency or 'Unknown'] += 1
181
+ else:
182
+ total += value * rate
183
+ return {'usd': str(total) if not missing_fx and not missing_amount_rows else None,
184
+ 'native': {k: str(v) for k, v in sorted(native.items())},
185
+ 'missing_fx': [{'date': d, 'currency': c, 'rows': n} for (d, c), n in sorted(missing_fx.items())],
186
+ 'missing_amount_rows': missing_amount_rows}
187
+
188
+
189
+ def merchant_breakdown(auth, weeks, rates):
190
+ """Group one merchant's observed attempts using full-period membership for every week."""
191
+ edges = (Decimal(0), Decimal(25), Decimal(50), Decimal(100), Decimal(200), Decimal(500))
192
+ bucket_labels = ['[0,25)', '[25,50)', '[50,100)', '[100,200)', '[200,500)', '[500,+inf)']
193
+ unknown = ('unknown',)
194
+ other = ('other',)
195
+
196
+ def value(row, field):
197
+ return (row.get(field) or '').strip()
198
+
199
+ def amount(row):
200
+ try:
201
+ n = Decimal(value(row, 'Transaction amount'))
202
+ if not n.is_finite() or n < 0:
203
+ return None
204
+ return n
205
+ except InvalidOperation:
206
+ return None
207
+
208
+ def usd(row):
209
+ n = amount(row)
210
+ currency = value(row, 'Transaction amount currency').upper()
211
+ rate = Decimal(1) if currency == 'USD' else rates.get((day(row['Timestamp']), currency))
212
+ return n * rate if n is not None and rate is not None else None
213
+
214
+ def category(row, dimension):
215
+ if dimension == 'bin_country':
216
+ v = value(row, 'Issuing bank country')
217
+ return ('value', v) if v else unknown
218
+ if dimension == 'card_product':
219
+ method, card = value(row, 'Payment method'), value(row, 'Card type')
220
+ return ('value', method, card) if method or card else unknown
221
+ if dimension == 'authentication':
222
+ v = value(row, 'Authentication method')
223
+ return ('value', v) if v else unknown
224
+ if dimension == 'decline_reason':
225
+ code, message = value(row, 'Pazien response code'), value(row, 'Response message')
226
+ return ('value', code, message) if code or message else unknown
227
+ converted = usd(row)
228
+ if converted is None:
229
+ return unknown
230
+ return ('bucket', str(max(i for i, edge in enumerate(edges) if converted >= edge)))
231
+
232
+ def label(key):
233
+ if key == unknown:
234
+ return 'Unknown'
235
+ if key == other:
236
+ return 'Other'
237
+ if key[0] == 'bucket':
238
+ return bucket_labels[int(key[1])]
239
+ return ' × '.join(part or 'Unknown' for part in key[1:])
240
+
241
+ def amount_sum(rows):
242
+ total, native, missing_fx = Decimal(0), defaultdict(Decimal), Counter()
243
+ missing_amount = 0
244
+ for row in rows:
245
+ n = amount(row)
246
+ if n is None:
247
+ missing_amount += 1
248
+ continue
249
+ currency = value(row, 'Transaction amount currency').upper()
250
+ d = day(row['Timestamp'])
251
+ native[currency or 'Unknown'] += n
252
+ rate = Decimal(1) if currency == 'USD' else rates.get((d, currency))
253
+ if rate is None:
254
+ missing_fx[d, currency or 'Unknown'] += 1
255
+ else:
256
+ total += n * rate
257
+ return {'usd': str(total) if not missing_fx and not missing_amount else None,
258
+ 'native': {k: str(v) for k, v in sorted(native.items())},
259
+ 'missing_fx': [{'date': d, 'currency': c, 'rows': n} for (d, c), n in sorted(missing_fx.items())],
260
+ 'missing_amount_rows': missing_amount}
261
+
262
+ def metrics(rows, denominator, observed):
263
+ if not observed:
264
+ return dict.fromkeys(('count', 'approvals', 'auth_rate', 'share', 'attempt_amount', 'approved_amount'))
265
+ approved = [r for r in rows if r.get('Response type', '').lower() == 'approval']
266
+ return {'count': len(rows), 'approvals': len(approved),
267
+ 'auth_rate': len(approved) / len(rows) if rows else None,
268
+ 'share': len(rows) / denominator if denominator else None,
269
+ 'attempt_amount': amount_sum(rows), 'approved_amount': amount_sum(approved)}
270
+
271
+ dimensions = []
272
+ for dimension, title in [('bin_country', '发卡行国家'), ('card_product', '卡产品:支付方式 × 卡类型'),
273
+ ('amount_bucket', '授权尝试金额区间 USD'), ('authentication', '持卡人认证状态'),
274
+ ('decline_reason', '未成功响应原因')]:
275
+ population = [r for r in auth if dimension != 'decline_reason' or r.get('Response type', '').lower() != 'approval']
276
+ categories = Counter(category(r, dimension) for r in population)
277
+ if dimension in ('bin_country', 'decline_reason'):
278
+ ranked = sorted((k for k in categories if k != unknown), key=lambda k: (-categories[k], k))
279
+ selected = set(ranked[:10])
280
+ keys = ranked[:10] + [other, unknown]
281
+ elif dimension == 'amount_bucket':
282
+ selected = None
283
+ keys = [('bucket', str(i)) for i in range(len(edges))] + [unknown]
284
+ else:
285
+ selected = None
286
+ keys = sorted(k for k in categories if k != unknown) + [unknown]
287
+
288
+ def group(row):
289
+ k = category(row, dimension)
290
+ return other if selected is not None and k != unknown and k not in selected else k
291
+
292
+ groups = []
293
+ for key in keys:
294
+ rows = [r for r in population if group(r) == key]
295
+ weekly = []
296
+ for week in weeks:
297
+ week_auth = [r for r in auth if week['start'] <= day(r['Timestamp']) <= week['end']]
298
+ week_population = [r for r in population if week['start'] <= day(r['Timestamp']) <= week['end']]
299
+ week_rows = [r for r in week_population if group(r) == key]
300
+ weekly.append({**{k: week[k] for k in ('week_start', 'week_end', 'start', 'end', 'days', 'partial_week')},
301
+ 'metrics': metrics(week_rows, len(week_population), bool(week_auth))})
302
+ groups.append({'key': json.dumps(key, ensure_ascii=False, separators=(',', ':')),
303
+ 'label': label(key), 'metrics': metrics(rows, len(population), bool(auth)), 'weeks': weekly})
304
+ dimensions.append({'key': dimension, 'label': title,
305
+ 'population': 'non_approvals' if dimension == 'decline_reason' else 'attempts',
306
+ 'groups': groups})
307
+ return dimensions
308
+
309
+
310
+ def summary(auth, settlement, dispute, rates, present):
311
+ approvals = sum(r.get('Response type', '').lower() == 'approval' for r in auth)
312
+ settled = [r for r in settlement if r.get('Settlement status') == 'Settled']
313
+ sales = [r for r in settled if r.get('Settlement type') == 'Sale']
314
+ refunds = [r for r in settled if r.get('Settlement type') == 'Refund']
315
+ other = len(settled) - len(sales) - len(refunds)
316
+ def settlement_metrics(rows):
317
+ result = amounts(rows, rates)
318
+ missing_identity = sum(not (r.get('Transaction ID') or '').strip() for r in rows)
319
+ if missing_identity:
320
+ result['usd'] = None
321
+ result['native'] = None
322
+ result['missing_identity_rows'] = missing_identity
323
+ return (None if missing_identity else len(rows)), result
324
+ sale_count, sales_amount = settlement_metrics(sales)
325
+ refund_count, refunds_amount = settlement_metrics(refunds)
326
+ def breakdown(field):
327
+ groups = defaultdict(list)
328
+ for row in auth:
329
+ groups[row.get(field, '') or 'Unknown'].append(row)
330
+ return [{'category': key, 'attempts': len(rows),
331
+ 'approvals': sum(r.get('Response type', '').lower() == 'approval' for r in rows),
332
+ 'auth_rate': sum(r.get('Response type', '').lower() == 'approval' for r in rows) / len(rows)}
333
+ for key, rows in sorted(groups.items())]
334
+ declined = [r for r in auth if r.get('Response type', '').lower() != 'approval']
335
+ reasons = Counter((r.get('Pazien response code', '') or 'Unknown', r.get('Response message', '') or 'Unknown') for r in declined)
336
+ daily = defaultdict(list)
337
+ for r in auth:
338
+ daily[day(r['Timestamp'])].append(r)
339
+ result = {'layer_observed': {k: present[k] and bool(rows) for k, rows in
340
+ (('auth', auth), ('settlement', settlement), ('dispute', dispute))},
341
+ 'attempts': len(auth), 'approvals': approvals, 'non_approvals': len(auth) - approvals,
342
+ 'response_types': counts(auth, 'Response type'),
343
+ 'approved_amount': approved_amounts(auth, rates),
344
+ 'auth_rate': approvals / len(auth) if auth else None,
345
+ 'settlement_statuses': counts(settlement, 'Settlement status'),
346
+ 'sale_count': sale_count, 'refund_count': refund_count, 'other_settlement_count': other,
347
+ 'sales': sales_amount, 'refunds': refunds_amount,
348
+ 'decline_codes': counts([r for r in auth if r.get('Response type', '').lower() != 'approval'], 'Pazien response code'),
349
+ 'countries': counts(auth, 'Issuing bank country'), 'authentication_methods': counts(auth, 'Authentication method'),
350
+ 'dispute_snapshots': len(dispute), 'dispute_lifecycle': counts(dispute, 'Lifecycle state'),
351
+ 'dispute_cycles': counts(dispute, 'Original cycle'), 'formal_dispute_rate': None,
352
+ 'daily_auth': [{'date': d, 'attempts': len(rows), 'approvals': sum(r.get('Response type', '').lower() == 'approval' for r in rows),
353
+ 'auth_rate': sum(r.get('Response type', '').lower() == 'approval' for r in rows) / len(rows)} for d, rows in sorted(daily.items())],
354
+ 'country_metrics': breakdown('Issuing bank country'), 'authentication_metrics': breakdown('Authentication method'),
355
+ 'decline_reasons': [{'code': code, 'message': message, 'count': n} for (code, message), n in sorted(reasons.items(), key=lambda x: (-x[1], x[0]))]}
356
+ if not result['layer_observed']['settlement']:
357
+ for key in ('sale_count', 'refund_count', 'other_settlement_count', 'sales', 'refunds', 'settlement_statuses'):
358
+ result[key] = None
359
+ if not result['layer_observed']['dispute']:
360
+ for key in ('dispute_snapshots', 'dispute_lifecycle', 'dispute_cycles'):
361
+ result[key] = None
362
+ return result
363
+
364
+
365
+ def analyze(root, start, end, fxpath):
366
+ raw, sources = read_sources(root)
367
+ rates, fxmeta = fx_load(fxpath)
368
+ present = {k: any(s['layer'] == k for s in sources) for k in ('auth', 'settlement', 'dispute')}
369
+ last_dates = {k: max((s['max_date'] for s in sources if s['layer'] == k and s['max_date']), default=None) for k in present}
370
+ requested_end = end
371
+ end = min(end, *(date.fromisoformat(d) for d in last_dates.values() if d))
372
+ if start > end:
373
+ raise ValueError('main-start is after the effective data ceiling')
374
+ layers, audit = {}, {}
375
+ for _, (layer, field, keys) in LAYERS.items():
376
+ layers[layer], audit[layer] = dedup(raw[layer], keys, layer == 'dispute')
377
+ for layer in ('auth', 'settlement'):
378
+ conflicts = audit[layer]['conflicting_identity_groups']
379
+ if conflicts:
380
+ raise ValueError(f'{layer}: {conflicts} conflicting identity group(s); report generation refused')
381
+ # Explicit IDs cannot safely share the output's bare merchant key across providers.
382
+ namespaces = defaultdict(set)
383
+ for rows in raw.values():
384
+ for row in rows:
385
+ merchant, namespace = row.get('Sub merchant ID', ''), provider_namespace(row)
386
+ if merchant:
387
+ namespaces[merchant].add(namespace)
388
+ if any(len(values) > 1 for values in namespaces.values()):
389
+ raise ValueError('Sub merchant ID reused across provider namespaces; report generation refused')
390
+ # A reference is evidence only within a nonempty provider namespace.
391
+ identities = defaultdict(set)
392
+ for layer in ('auth', 'settlement'):
393
+ for row in raw[layer]:
394
+ namespace = provider_namespace(row)
395
+ if namespace and row.get('Transaction ID') and row.get('Sub merchant ID'):
396
+ identities[namespace, row['Transaction ID']].add(row['Sub merchant ID'])
397
+ mapping = Counter()
398
+ for layer, rows in layers.items():
399
+ for row in rows:
400
+ merchant = row.get('Sub merchant ID', '')
401
+ method = 'source'
402
+ if not merchant:
403
+ ref = row.get('Original transaction ID' if layer == 'dispute' else 'Transaction ID', '')
404
+ namespace = provider_namespace(row)
405
+ candidates = identities.get((namespace, ref), set()) if namespace and ref else set()
406
+ if len(candidates) == 1:
407
+ merchant, method = next(iter(candidates)), 'unique_transaction_reference'
408
+ else:
409
+ method = 'unknown_ambiguous' if candidates else 'unknown_unmatched'
410
+ row['_merchant'] = merchant
411
+ mapping[layer, merchant, row.get('Billing descriptor', ''), method, provider_namespace(row)] += 1
412
+ fields = {'auth': 'Timestamp', 'settlement': 'Settlement date', 'dispute': 'Chargeback date'}
413
+ def window(a, b):
414
+ return {k: [r for r in v if a <= day(r[fields[k]]) <= b] for k, v in layers.items()}
415
+ def calc(w):
416
+ return summary(w['auth'], w['settlement'], w['dispute'], rates, present)
417
+ main = window(start.isoformat(), end.isoformat())
418
+ previous_start, previous_end = shift(start, -1), shift(end, -1)
419
+ previous = window(previous_start.isoformat(), previous_end.isoformat())
420
+ names = defaultdict(Counter)
421
+ for (_, merchant, descriptor, _, _), n in mapping.items():
422
+ if descriptor:
423
+ names[merchant][descriptor] += n
424
+ ids = sorted({r['_merchant'] for rows in main.values() for r in rows})
425
+ merchants = []
426
+ for mid in ids:
427
+ sub = {k: [r for r in v if r['_merchant'] == mid] for k, v in main.items()}
428
+ name = sorted(names[mid], key=lambda x: (-names[mid][x], x))[0] if names[mid] else 'Unknown'
429
+ merchants.append({'id': mid or None, 'name': name, **calc(sub)})
430
+ merchant_weeks = []
431
+ for merchant in merchants:
432
+ mid = merchant['id'] or ''
433
+ weeks = []
434
+ monday = start - timedelta(days=start.weekday())
435
+ while monday <= end:
436
+ sunday = monday + timedelta(days=6)
437
+ first, last = max(monday, start), min(sunday, end)
438
+ observed = window(first.isoformat(), last.isoformat())
439
+ metrics = calc({k: [r for r in rows if r['_merchant'] == mid] for k, rows in observed.items()})
440
+ days = (last - first).days + 1
441
+ comparison = {'eligible': False, 'reason': '', 'attempts_pct': None,
442
+ 'auth_rate_pp': None, 'sales_usd_pct': None}
443
+ if not weeks:
444
+ comparison['reason'] = 'No previous week; observed exports only, continuous coverage unverified'
445
+ elif days != 7 or weeks[-1]['days'] != 7:
446
+ comparison['reason'] = 'Partial week; observed exports only, continuous coverage unverified'
447
+ else:
448
+ prior = weeks[-1]['metrics']
449
+ if metrics['layer_observed']['auth'] and prior['layer_observed']['auth']:
450
+ comparison['attempts_pct'] = (metrics['attempts'] - prior['attempts']) / prior['attempts']
451
+ comparison['auth_rate_pp'] = 100 * (metrics['auth_rate'] - prior['auth_rate'])
452
+ if metrics['layer_observed']['settlement'] and prior['layer_observed']['settlement']:
453
+ current_sales, prior_sales = metrics['sales']['usd'], prior['sales']['usd']
454
+ if current_sales is not None and prior_sales is not None and Decimal(prior_sales) > 0:
455
+ comparison['sales_usd_pct'] = float((Decimal(current_sales) - Decimal(prior_sales)) / Decimal(prior_sales))
456
+ comparison['eligible'] = any(comparison[k] is not None for k in ('attempts_pct', 'auth_rate_pp', 'sales_usd_pct'))
457
+ comparison['reason'] = ('Available metrics compare observed exports only; continuous coverage unverified'
458
+ if comparison['eligible'] else
459
+ 'No comparable observed layers or positive baseline; observed exports only, continuous coverage unverified')
460
+ weeks.append({'week_start': monday.isoformat(), 'week_end': sunday.isoformat(),
461
+ 'start': first.isoformat(), 'end': last.isoformat(), 'days': days,
462
+ 'partial_week': days != 7, 'metrics': metrics, 'comparison': comparison})
463
+ monday += timedelta(days=7)
464
+ merchant_weeks.append({'id': merchant['id'], 'name': merchant['name'], 'weeks': weeks})
465
+ merchant_breakdowns = [{'id': merchant['id'], 'name': merchant['name'],
466
+ 'dimensions': merchant_breakdown(
467
+ [r for r in main['auth'] if r['_merchant'] == (merchant['id'] or '')],
468
+ merchant_weeks[i]['weeks'], rates)}
469
+ for i, merchant in enumerate(merchants)]
470
+ current_start = max(start, end - timedelta(days=end.weekday()))
471
+ detail_periods = [
472
+ {'key': 'previous', 'label': '上周同期', 'start': (current_start - timedelta(days=7)).isoformat(),
473
+ 'end': (end - timedelta(days=7)).isoformat()},
474
+ {'key': 'current', 'label': '本周', 'start': current_start.isoformat(), 'end': end.isoformat()},
475
+ ]
476
+ detail_merchants = []
477
+ for merchant in merchants:
478
+ mid = merchant['id'] or ''
479
+ period_rows = [{k: [r for r in rows if r['_merchant'] == mid and
480
+ period['start'] <= day(r[fields[k]]) <= period['end']]
481
+ for k, rows in main.items()} for period in detail_periods]
482
+ grid = []
483
+ for period in detail_periods:
484
+ first, last = date.fromisoformat(period['start']), date.fromisoformat(period['end'])
485
+ monday = first - timedelta(days=first.weekday())
486
+ grid.append({'week_start': monday.isoformat(), 'week_end': (monday + timedelta(days=6)).isoformat(),
487
+ 'start': period['start'], 'end': period['end'], 'days': (last - first).days + 1,
488
+ 'partial_week': (last - first).days != 6})
489
+ dimensions = merchant_breakdown([r for rows in period_rows for r in rows['auth']], grid, rates)
490
+ for dimension in dimensions:
491
+ for group in dimension['groups']:
492
+ group['periods'] = [{'key': period['key'], 'start': period['start'], 'end': period['end'],
493
+ 'metrics': week['metrics']}
494
+ for period, week in zip(detail_periods, group.pop('weeks'))]
495
+ detail_merchants.append({'id': merchant['id'], 'name': merchant['name'],
496
+ 'periods': [{'key': p['key'], 'start': p['start'], 'end': p['end'],
497
+ 'metrics': calc(rows)} for p, rows in zip(detail_periods, period_rows)],
498
+ 'dimensions': dimensions})
499
+ merchant_detail_comparison = {'periods': detail_periods, 'merchants': detail_merchants,
500
+ 'scope': {'reason': 'same weekdays; observed exports; unavailable outside main window',
501
+ 'coverage': 'Observed rows only; continuous coverage unverified'}}
502
+ portfolio = calc(main)
503
+ all_ids = sorted({r['_merchant'] for rows in layers.values() for r in rows})
504
+ comparison_merchants = []
505
+ for mid in all_ids:
506
+ current = [r for r in main['auth'] if r['_merchant'] == mid]
507
+ prior = [r for r in previous['auth'] if r['_merchant'] == mid]
508
+ c, p = len(current), len(prior)
509
+ ca = sum(r.get('Response type', '').lower() == 'approval' for r in current)
510
+ pa = sum(r.get('Response type', '').lower() == 'approval' for r in prior)
511
+ comparison_merchants.append({'id': mid or None, 'name': sorted(names[mid], key=lambda x: (-names[mid][x], x))[0] if names[mid] else 'Unknown',
512
+ 'current_attempts': c, 'current_approvals': ca, 'current_auth_rate': ca / c if c else None,
513
+ 'previous_attempts': p, 'previous_approvals': pa, 'previous_auth_rate': pa / p if p else None})
514
+ nc, np = len(main['auth']), len(previous['auth'])
515
+ components = {'common_rate_pp': 0.0, 'common_mix_pp': 0.0, 'current_only_pp': 0.0, 'prior_only_pp': 0.0}
516
+ for item in comparison_merchants:
517
+ parts = dict.fromkeys(components, 0.0)
518
+ c, p = item['current_attempts'], item['previous_attempts']
519
+ if nc and np:
520
+ if c and p:
521
+ wc, wp = c / nc, p / np
522
+ rc, rp = item['current_auth_rate'], item['previous_auth_rate']
523
+ parts['common_rate_pp'] = 100 * (rc - rp) * (wc + wp) / 2
524
+ parts['common_mix_pp'] = 100 * (wc - wp) * (rc + rp) / 2
525
+ elif c:
526
+ parts['current_only_pp'] = 100 * item['current_approvals'] / nc
527
+ elif p:
528
+ parts['prior_only_pp'] = -100 * item['previous_approvals'] / np
529
+ for k in components:
530
+ components[k] += parts[k]
531
+ item['decomposition_pp'] = parts if nc and np else None
532
+ decomposition = {'method': 'symmetric common-merchant rate and mix; one-period approval contributions; portfolio denominators',
533
+ 'delta_pp': 100 * (portfolio['auth_rate'] - calc(previous)['auth_rate']) if nc and np else None,
534
+ 'components_pp': components if nc and np else None}
535
+ trends = []
536
+ for offset in range(-5, 1):
537
+ month = shift(end.replace(day=1), offset)
538
+ last = month.replace(day=calendar.monthrange(month.year, month.month)[1])
539
+ if last > end:
540
+ last = end
541
+ w = window(month.isoformat(), last.isoformat())
542
+ trends.append({'start': month.isoformat(), 'end': last.isoformat(), 'coverage': 'observed rows only; continuity unverified',
543
+ 'layer_observed': {k: bool(v) for k, v in w.items()},
544
+ 'metrics': calc(w) if any(w.values()) else None})
545
+ # Use all raw observations for collision detection, including rows excluded by deduplication.
546
+ order_merchants = defaultdict(set)
547
+ for layer in ('auth', 'settlement'):
548
+ for r in raw[layer]:
549
+ if r.get('Order ID'):
550
+ order_merchants[r['Order ID']].add(r.get('Sub merchant ID', ''))
551
+ auth_variants = defaultdict(set)
552
+ for r in raw['auth']:
553
+ if r.get('Transaction ID') and r.get('Attempt'):
554
+ auth_variants[r['Transaction ID'], r['Attempt']].add(row_variant({k: v for k, v in r.items() if not k.startswith('_')}))
555
+ conflicted_orders = {r.get('Order ID') for r in raw['auth'] if r.get('Order ID') and
556
+ len(auth_variants.get((r.get('Transaction ID'), r.get('Attempt')), ())) > 1}
557
+ approved = defaultdict(list)
558
+ for r in layers['auth']:
559
+ if r.get('Order ID') and r.get('Response type', '').lower() == 'approval' and day(r['Timestamp']) <= end.isoformat():
560
+ approved[r['Order ID'], r['_merchant']].append(r)
561
+ def journey(rows):
562
+ if not present['settlement']:
563
+ return None
564
+ result = {'matched_sales': 0, 'unmatched_sales': 0, 'ambiguous_sales': 0, 'linked_prior_period_approval': 0}
565
+ for r in rows:
566
+ if r.get('Settlement type') != 'Sale' or r.get('Settlement status') != 'Settled':
567
+ continue
568
+ order, mid = r.get('Order ID', ''), r['_merchant']
569
+ candidates = approved.get((order, mid), [])
570
+ if not order or not mid or not r.get('Sub merchant ID'):
571
+ result['unmatched_sales'] += 1
572
+ elif order in conflicted_orders or len(order_merchants[order]) != 1 or len(candidates) > 1:
573
+ result['ambiguous_sales'] += 1
574
+ elif len(candidates) == 1 and day(candidates[0]['Timestamp']) <= day(r['Settlement date']):
575
+ result['matched_sales'] += 1
576
+ if day(candidates[0]['Timestamp']) < start.isoformat():
577
+ result['linked_prior_period_approval'] += 1
578
+ else:
579
+ result['unmatched_sales'] += 1
580
+ return result
581
+ portfolio['journey'] = journey(main['settlement'])
582
+ for m in merchants:
583
+ m['journey'] = journey([r for r in main['settlement'] if r['_merchant'] == (m['id'] or '')])
584
+ history = []
585
+ for offset in range(-11, 1):
586
+ month = shift(end.replace(day=1), offset)
587
+ last = min(month.replace(day=calendar.monthrange(month.year, month.month)[1]), end)
588
+ rows = window(month.isoformat(), last.isoformat())['dispute']
589
+ history.append({'start': month.isoformat(), 'end': last.isoformat(), 'layer_present': present['dispute'],
590
+ 'observed': bool(rows), 'snapshot_count': len(rows) if rows else None,
591
+ 'stage_labels': counts(rows, 'Lifecycle state') if rows else None, 'formal_rate': None})
592
+ reconciliation = {'sources': sources, 'deduplication': audit,
593
+ 'reference_cross_merchant_collisions': sum(len(v) > 1 for v in identities.values()),
594
+ 'reconciliation': {k: {'portfolio_rows': len(main[k]), 'merchant_rows_including_unknown': sum(sum(r['_merchant'] == mid for r in main[k]) for mid in ids)} for k in main},
595
+ 'excluded': 'Unrecognized files; invalid and future 340FRD files are recorded in risk_history', 'coverage': 'Observed date ranges do not establish continuous coverage',
596
+ 'fx': fxmeta, 'disputes': 'Distinct observed snapshots, not verified openings; no formal rates',
597
+ 'timezone': 'Source timestamps are naive; timezone is unknown'}
598
+ merchant_risk_history, reconciliation['risk_history'] = risk_history.build(
599
+ root, end, comparison_merchants, layers['settlement'], layers['dispute'])
600
+ return {'schema_version': 1, 'metadata': {'main_start': start.isoformat(), 'main_end': end.isoformat(),
601
+ 'requested_main_end': requested_end.isoformat(), 'layers_present': present, 'layer_last_dates': last_dates,
602
+ 'comparison_start': previous_start.isoformat(), 'comparison_end': previous_end.isoformat(),
603
+ 'auth_date': 'Timestamp', 'settlement_date': 'Settlement date', 'dispute_denominator_date': 'Timestamp (transaction/capture date)', 'fx_date': 'Timestamp', 'rate_unit': 'fraction'},
604
+ 'merchant_risk_history': merchant_risk_history,
605
+ 'portfolio': portfolio, 'merchants': merchants, 'merchant_weeks': merchant_weeks, 'merchant_breakdowns': merchant_breakdowns, 'merchant_detail_comparison': merchant_detail_comparison, 'trends': trends, 'dispute_history': history,
606
+ 'comparison': calc(previous), 'comparison_merchants': comparison_merchants, 'comparison_decomposition': decomposition, 'quality': reconciliation}, mapping
607
+
608
+
609
+ def main():
610
+ p = argparse.ArgumentParser(description=__doc__)
611
+ p.add_argument('--input', type=Path, required=True)
612
+ p.add_argument('--output', type=Path, required=True)
613
+ p.add_argument('--main-start', type=date.fromisoformat, required=True)
614
+ p.add_argument('--main-end', type=date.fromisoformat, required=True)
615
+ p.add_argument('--fx-rates', type=Path, help='JSON date -> currency -> USD per unit; caller provides provenance')
616
+ a = p.parse_args()
617
+ if a.main_start > a.main_end:
618
+ p.error('main-start must not be after main-end')
619
+ try:
620
+ facts, mapping = analyze(a.input, a.main_start, a.main_end, a.fx_rates)
621
+ a.output.mkdir(parents=True, exist_ok=True)
622
+ for name, data in [('facts.json', facts), ('coverage-and-reconciliation.json', facts['quality'])]:
623
+ (a.output / name).write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
624
+ with (a.output / 'merchant-mapping.csv').open('w', encoding='utf-8', newline='') as f:
625
+ w = safe_writer(f); w.writerow(['layer', 'merchant_id', 'descriptor', 'attribution', 'provider_namespace', 'rows'])
626
+ for key, n in sorted(mapping.items()): w.writerow([*key, n])
627
+ with (a.output / 'merchant-metrics.csv').open('w', encoding='utf-8', newline='') as f:
628
+ w = safe_writer(f, numeric_columns={6, 8}); w.writerow(['merchant_id', 'name', 'attempts', 'approvals', 'auth_rate', 'sale_count', 'sales_usd', 'refund_count', 'refunds_usd', 'dispute_snapshots'])
629
+ for m in facts['merchants']:
630
+ w.writerow([m['id'], m['name'], m['attempts'] if m['layer_observed']['auth'] else None, m['approvals'] if m['layer_observed']['auth'] else None, m['auth_rate'], m['sale_count'], m['sales']['usd'] if m['sales'] else None, m['refund_count'], m['refunds']['usd'] if m['refunds'] else None, m['dispute_snapshots']])
631
+ except (ValueError, OSError, KeyError, json.JSONDecodeError) as e:
632
+ p.exit(1, f'error: {e}\n')
633
+
634
+
635
+ if __name__ == '__main__':
636
+ main()