@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.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +298 -216
- package/README.md +3 -3
- package/package.json +10 -8
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +35 -37
- package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
- package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
- package/skills/app-dev/android-foundation/references/setup.md +24 -9
- package/skills/app-dev/android-foundation/references/workspace.md +81 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +15 -10
- package/skills/app-dev/ios-foundation/references/setup.md +25 -9
- package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +3 -3
- package/skills/core/distill-writing/references/extraction.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +82 -12
- package/skills/core/editorial-review/references/checklist.md +26 -1
- package/skills/core/house-style/SKILL.md +8 -3
- package/skills/core/house-style/references/analytical-reporting.md +123 -0
- package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
- package/skills/core/house-style/references/analytical-writing-research.md +129 -0
- package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
- package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
- package/skills/core/house-style/references/document-workflow.md +193 -0
- package/skills/core/house-style/references/initialization.md +66 -0
- package/skills/core/house-style/references/persistence.md +9 -4
- package/skills/core/house-style/references/rules.md +11 -4
- package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
- package/skills/core/house-style/templates/analytical-report.md +54 -0
- package/skills/core/house-style/templates/gap-analysis.md +1 -1
- package/skills/core/plain-language/SKILL.md +2 -2
- package/skills/core/polish-cn/SKILL.md +9 -2
- package/skills/core/reg-parser/SKILL.md +3 -3
- package/skills/core/terminology/SKILL.md +8 -4
- package/skills/core/write-doc/SKILL.md +40 -151
- package/skills/core/write-report/SKILL.md +129 -0
- package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
- package/skills/engineering/better-review/SKILL.md +37 -37
- package/skills/engineering/better-review/references/passes.md +35 -41
- package/skills/engineering/better-review/references/result.md +10 -9
- package/skills/engineering/e2e-test/SKILL.md +17 -17
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
- package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
- package/skills/engineering/e2e-test/references/reviewing.md +9 -11
- package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
- package/skills/engineering/engineering/SKILL.md +201 -0
- package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
- package/skills/engineering/engineering/references/feature-delivery.md +8 -0
- package/skills/engineering/engineering/references/init-scope.md +45 -0
- package/skills/engineering/engineering/references/scope-management.md +85 -0
- package/skills/engineering/engineering/scripts/init.mjs +160 -0
- package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/ensure-coverage/SKILL.md +22 -109
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
- package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
- package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
- package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +3 -1
- package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
- package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
- package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
- package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/engineering/resolve-issues/SKILL.md +40 -61
- package/skills/engineering/resolve-issues/references/ci.md +13 -3
- package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
- package/skills/engineering/resolve-issues/references/done.md +11 -2
- package/skills/engineering/resolve-issues/references/expire.md +9 -2
- package/skills/engineering/resolve-issues/references/implementation.md +33 -17
- package/skills/engineering/resolve-issues/references/preflight.md +3 -1
- package/skills/engineering/resolve-issues/references/principles.md +52 -0
- package/skills/engineering/resolve-issues/references/review.md +25 -15
- package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
- package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
- package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +7 -5
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +19 -4
- package/skills/engineering/test-quality/references/lint.md +1 -1
- package/skills/engineering/test-quality/references/principles.md +2 -2
- package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
- package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
- package/skills/risk-compliance/contract-review/SKILL.md +6 -1
- package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
- package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
- package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
- package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
- package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
- package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
- package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
- package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
- package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
- package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
- package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
- package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
- package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
- package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
- package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
- package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
- package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
- package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
- package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
- package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
- package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
- package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
- package/skills/engineering/contributing/SKILL.md +0 -70
- package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
- package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
- package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
- package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
- package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
- package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
- /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
- /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
- /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
- /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
- /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()
|