|
10 | 10 | from typing import Any, Literal |
11 | 11 | from uuid import uuid4 |
12 | 12 |
|
13 | | -LedgerKind = Literal["admin_recharge", "payment_recharge", "dataset_reward", "freeze", "settle", "release"] |
| 13 | +LedgerKind = Literal[ |
| 14 | + "admin_recharge", |
| 15 | + "payment_recharge", |
| 16 | + "dataset_reward", |
| 17 | + "dataset_access_charge", |
| 18 | + "dataset_access_reward", |
| 19 | + "freeze", |
| 20 | + "settle", |
| 21 | + "release", |
| 22 | +] |
14 | 23 | PaymentOrderStatus = Literal["pending", "paid", "cancelled"] |
15 | 24 |
|
16 | 25 |
|
@@ -113,6 +122,28 @@ def to_dict(self) -> dict[str, Any]: |
113 | 122 | } |
114 | 123 |
|
115 | 124 |
|
| 125 | +@dataclass(frozen=True) |
| 126 | +class DatasetAccessGrant: |
| 127 | + grant_id: str |
| 128 | + username: str |
| 129 | + dataset_id: str |
| 130 | + points_spent: int |
| 131 | + contributor_username: str = "" |
| 132 | + contributor_points: int = 0 |
| 133 | + created_at: str = "" |
| 134 | + |
| 135 | + def to_dict(self) -> dict[str, Any]: |
| 136 | + return { |
| 137 | + "grantId": self.grant_id, |
| 138 | + "username": self.username, |
| 139 | + "datasetId": self.dataset_id, |
| 140 | + "pointsSpent": self.points_spent, |
| 141 | + "contributorUsername": self.contributor_username, |
| 142 | + "contributorPoints": self.contributor_points, |
| 143 | + "createdAt": self.created_at, |
| 144 | + } |
| 145 | + |
| 146 | + |
116 | 147 | class AccountLedger: |
117 | 148 | """File-backed wallet ledger. |
118 | 149 |
|
@@ -285,6 +316,87 @@ def grant_dataset_reward( |
285 | 316 | self._save(state) |
286 | 317 | return wallet, record, True |
287 | 318 |
|
| 319 | + def redeem_dataset_access( |
| 320 | + self, |
| 321 | + username: str, |
| 322 | + dataset_id: str, |
| 323 | + price_points: int, |
| 324 | + *, |
| 325 | + contributor_username: str = "", |
| 326 | + contributor_share_bps: int = 5000, |
| 327 | + reason: str = "public dataset access", |
| 328 | + ) -> tuple[Wallet, DatasetAccessGrant, BillingRecord | None, BillingRecord | None, bool]: |
| 329 | + if price_points < 0: |
| 330 | + raise ValueError("price_points must be non-negative") |
| 331 | + if contributor_share_bps < 0 or contributor_share_bps > 10_000: |
| 332 | + raise ValueError("contributor_share_bps must be between 0 and 10000") |
| 333 | + username = _clean_username(username) |
| 334 | + dataset_id = dataset_id.strip() |
| 335 | + if not dataset_id: |
| 336 | + raise ValueError("dataset_id is required") |
| 337 | + contributor_username = contributor_username.strip() |
| 338 | + with self._lock: |
| 339 | + state = self._load() |
| 340 | + for payload in state.get("datasetAccessGrants", []): |
| 341 | + grant = _dataset_access_grant_from_payload(payload) |
| 342 | + if grant.username == username and grant.dataset_id == dataset_id: |
| 343 | + return self._wallet_from_state(state, username), grant, None, None, False |
| 344 | + |
| 345 | + wallet = self._wallet_from_state(state, username) |
| 346 | + if wallet.reward_points < price_points: |
| 347 | + raise ValueError("insufficient credit points") |
| 348 | + |
| 349 | + wallet = Wallet( |
| 350 | + username=username, |
| 351 | + balance_cents=wallet.balance_cents, |
| 352 | + frozen_cents=wallet.frozen_cents, |
| 353 | + reward_points=wallet.reward_points - price_points, |
| 354 | + updated_at=_now(), |
| 355 | + ) |
| 356 | + buyer_record = self._append_record( |
| 357 | + state, |
| 358 | + wallet, |
| 359 | + "dataset_access_charge", |
| 360 | + -price_points, |
| 361 | + reason=reason, |
| 362 | + job_id=dataset_id, |
| 363 | + ) |
| 364 | + contributor_points = 0 |
| 365 | + contributor_record: BillingRecord | None = None |
| 366 | + if contributor_username and contributor_username != username and price_points: |
| 367 | + contributor_points = price_points * contributor_share_bps // 10_000 |
| 368 | + if contributor_points: |
| 369 | + contributor_wallet = self._wallet_from_state(state, contributor_username) |
| 370 | + contributor_wallet = Wallet( |
| 371 | + username=contributor_username, |
| 372 | + balance_cents=contributor_wallet.balance_cents, |
| 373 | + frozen_cents=contributor_wallet.frozen_cents, |
| 374 | + reward_points=contributor_wallet.reward_points + contributor_points, |
| 375 | + updated_at=_now(), |
| 376 | + ) |
| 377 | + contributor_record = self._append_record( |
| 378 | + state, |
| 379 | + contributor_wallet, |
| 380 | + "dataset_access_reward", |
| 381 | + contributor_points, |
| 382 | + reason="public dataset reuse reward", |
| 383 | + job_id=dataset_id, |
| 384 | + ) |
| 385 | + self._save_wallet(state, contributor_wallet) |
| 386 | + grant = DatasetAccessGrant( |
| 387 | + grant_id=uuid4().hex, |
| 388 | + username=username, |
| 389 | + dataset_id=dataset_id, |
| 390 | + points_spent=price_points, |
| 391 | + contributor_username=contributor_username, |
| 392 | + contributor_points=contributor_points, |
| 393 | + created_at=_now(), |
| 394 | + ) |
| 395 | + state.setdefault("datasetAccessGrants", []).append(grant.to_dict()) |
| 396 | + self._save_wallet(state, wallet) |
| 397 | + self._save(state) |
| 398 | + return wallet, grant, buyer_record, contributor_record, True |
| 399 | + |
288 | 400 | def admin_recharge(self, username: str, amount_cents: int, *, reason: str = "admin recharge") -> tuple[Wallet, BillingRecord]: |
289 | 401 | if amount_cents <= 0: |
290 | 402 | raise ValueError("amount_cents must be positive") |
@@ -610,7 +722,7 @@ def _save_wallet(self, state: dict[str, Any], wallet: Wallet) -> None: |
610 | 722 |
|
611 | 723 | def _load(self) -> dict[str, Any]: |
612 | 724 | if not self.path.is_file(): |
613 | | - return {"wallets": {}, "records": [], "paymentOrders": []} |
| 725 | + return {"wallets": {}, "records": [], "paymentOrders": [], "datasetAccessGrants": []} |
614 | 726 | return json.loads(self.path.read_text(encoding="utf-8")) |
615 | 727 |
|
616 | 728 | def _save(self, state: dict[str, Any]) -> None: |
@@ -659,5 +771,17 @@ def _order_from_payload(payload: dict[str, Any]) -> PaymentOrder: |
659 | 771 | ) |
660 | 772 |
|
661 | 773 |
|
| 774 | +def _dataset_access_grant_from_payload(payload: dict[str, Any]) -> DatasetAccessGrant: |
| 775 | + return DatasetAccessGrant( |
| 776 | + grant_id=str(payload.get("grantId") or ""), |
| 777 | + username=str(payload.get("username") or ""), |
| 778 | + dataset_id=str(payload.get("datasetId") or ""), |
| 779 | + points_spent=int(payload.get("pointsSpent", 0) or 0), |
| 780 | + contributor_username=str(payload.get("contributorUsername") or ""), |
| 781 | + contributor_points=int(payload.get("contributorPoints", 0) or 0), |
| 782 | + created_at=str(payload.get("createdAt") or ""), |
| 783 | + ) |
| 784 | + |
| 785 | + |
662 | 786 | def _now() -> str: |
663 | 787 | return datetime.now(tz=timezone.utc).isoformat() |
0 commit comments