snaplii-cli 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ *.egg
8
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: snaplii-cli
3
+ Version: 0.4.0
4
+ Summary: Browse, purchase, and manage gift cards through Snaplii — safe payments for AI agents
5
+ Project-URL: Homepage, https://github.com/SnapPayInc/ai-passport
6
+ Project-URL: Repository, https://github.com/SnapPayInc/ai-passport
7
+ Author-email: Snaplii Inc <charles.zhang@snaplii.com>
8
+ License: Apache-2.0
9
+ Keywords: ai-agent,cli,gift-cards,payments,snaplii
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Office/Business :: Financial
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: click<9,>=8.1
18
+ Requires-Dist: httpx<1,>=0.27
19
+ Requires-Dist: keyring>=25.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-httpx<1,>=0.35; extra == 'dev'
22
+ Requires-Dist: pytest<9,>=8.0; extra == 'dev'
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "snaplii-cli"
7
+ version = "0.4.0"
8
+ description = "Browse, purchase, and manage gift cards through Snaplii — safe payments for AI agents"
9
+ requires-python = ">=3.9"
10
+ license = {text = "Apache-2.0"}
11
+ authors = [{name = "Snaplii Inc", email = "charles.zhang@snaplii.com"}]
12
+ keywords = ["snaplii", "gift-cards", "ai-agent", "payments", "cli"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Office/Business :: Financial",
20
+ ]
21
+ dependencies = [
22
+ "click>=8.1,<9",
23
+ "httpx>=0.27,<1",
24
+ "keyring>=25.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/SnapPayInc/ai-passport"
29
+ Repository = "https://github.com/SnapPayInc/ai-passport"
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0,<9",
34
+ "pytest-httpx>=0.35,<1",
35
+ ]
36
+
37
+ [project.scripts]
38
+ snaplii = "snaplii.cli:_cli"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/snaplii"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from snaplii.cli import main
2
+
3
+ main()
@@ -0,0 +1,67 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from snaplii.client import GatewayClient
6
+ from snaplii.commands.apikey import apikey_group
7
+ from snaplii.commands.browse import browse_group
8
+ from snaplii.commands.config import config_group
9
+ from snaplii.commands.giftcard import giftcard_group
10
+ from snaplii.commands.init import init_cmd
11
+ from snaplii.commands.purchase import purchase_cmd
12
+ from snaplii.commands.smart import smart_group
13
+ from snaplii.config_store import ConfigStore
14
+ from snaplii.exceptions import SnapliiCliError
15
+ from snaplii.output import print_error
16
+
17
+ _DEFAULT_BASE_URL = "https://aipayment.snaplii.com"
18
+
19
+
20
+ @click.group()
21
+ @click.option(
22
+ "--base-url",
23
+ envvar="SNAPLII_BASE_URL",
24
+ default=None,
25
+ help="Gateway base URL (overrides config)",
26
+ )
27
+ @click.pass_context
28
+ def main(ctx, base_url):
29
+ """Snaplii Agent Gateway CLI.
30
+
31
+ Browse gift card brands, manage your cards, purchase new ones,
32
+ and manage API keys.
33
+ """
34
+ ctx.ensure_object(dict)
35
+ store = ConfigStore()
36
+ resolved_url = base_url or store.get("base_url", _DEFAULT_BASE_URL)
37
+ ctx.obj["config_store"] = store
38
+ ctx.obj["client"] = GatewayClient(resolved_url, store)
39
+
40
+
41
+ main.add_command(init_cmd)
42
+ main.add_command(browse_group)
43
+ main.add_command(giftcard_group)
44
+ main.add_command(purchase_cmd)
45
+ main.add_command(apikey_group)
46
+ main.add_command(smart_group)
47
+ main.add_command(config_group)
48
+
49
+
50
+ @main.command("help")
51
+ @click.pass_context
52
+ def help_cmd(ctx):
53
+ """Show full CLI documentation."""
54
+ click.echo(ctx.parent.get_help())
55
+
56
+
57
+ def _cli():
58
+ try:
59
+ main(standalone_mode=False)
60
+ except click.exceptions.Exit:
61
+ pass
62
+ except click.UsageError as e:
63
+ print_error({"error": "Usage error", "message": str(e)})
64
+ sys.exit(1)
65
+ except SnapliiCliError as e:
66
+ print_error(e.to_dict())
67
+ sys.exit(1)
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from snaplii.config_store import ConfigStore
6
+ from snaplii.exceptions import ConfigError, GatewayApiError, GatewayConnectionError
7
+
8
+
9
+ class GatewayClient:
10
+ def __init__(self, base_url: str, config_store: ConfigStore):
11
+ self._base_url = base_url.rstrip("/")
12
+ self._config = config_store
13
+ self._http = httpx.Client(timeout=30.0)
14
+
15
+ # ── Auth ──────────────────────────────────────────────────────
16
+
17
+ def login(self, agent_id: str, api_key: str) -> dict:
18
+ resp = self._post("/v2/auth/token", json={
19
+ "agent_id": agent_id,
20
+ "api_key": api_key,
21
+ })
22
+ token = resp.get("access_token")
23
+ expires_in = resp.get("expires_in", 3600)
24
+ if token:
25
+ self._config.cache_token(token, expires_in)
26
+ return resp
27
+
28
+ # ── User cards ────────────────────────────────────────────────
29
+
30
+ def list_user_cards(self, status: str = "ACTIVE", page: int = 1, page_size: int = 20) -> dict:
31
+ return self._get("/v2/cards", params={
32
+ "status": status,
33
+ "page": str(page),
34
+ "pageSize": str(page_size),
35
+ })
36
+
37
+ def get_card_detail(self, card_no: str) -> dict:
38
+ return self._get(f"/v2/cards/{card_no}")
39
+
40
+ # ── Card browsing ─────────────────────────────────────────────
41
+
42
+ def get_all_card_tags(self, channel: str = "HOME_PAGE", location_prov: str = "ON") -> dict:
43
+ resp = self._get("/v2/card-brands", params={
44
+ "channel": channel,
45
+ "locationProv": location_prov,
46
+ })
47
+ # Gateway returns list directly; normalize to {"data": [...]}
48
+ if isinstance(resp, list):
49
+ return {"data": resp}
50
+ return resp
51
+
52
+ def get_card_brand_by_id(self, card_brand_id: str) -> dict:
53
+ resp = self._get(f"/v2/card-brands/{card_brand_id}", params={
54
+ "showDetail": "true",
55
+ })
56
+ # Gateway returns detail directly; normalize to {"data": {...}}
57
+ if isinstance(resp, dict) and "data" not in resp and "cardBrandId" in resp:
58
+ return {"data": resp}
59
+ return resp
60
+
61
+ # ── Purchase ──────────────────────────────────────────────────
62
+
63
+ def create_order_and_pay(
64
+ self,
65
+ item_id: str,
66
+ price: str,
67
+ payment_method: str = "SNAPLII_CASH",
68
+ payment_token: str | None = None,
69
+ location_prov: str = "ON",
70
+ ) -> dict:
71
+ payment_ctx = {
72
+ "specifiedPrimaryPaymentMethod": payment_method,
73
+ "voucherOption": "BEST_FIT",
74
+ "cashbackOption": "USE",
75
+ }
76
+ if payment_token:
77
+ payment_ctx["specifiedPrimaryPaymentToken"] = payment_token
78
+ return self._post("/v2/purchase", json={
79
+ "orderInfo": {
80
+ "orderType": "GIFT_CARD",
81
+ "item": {"itemId": item_id, "price": price},
82
+ "orderContext": {"giftOrder": "false"},
83
+ "businessChannel": "APP",
84
+ },
85
+ "paymentContext": payment_ctx,
86
+ "delivery": {"type": "WALLET", "immediateSend": "true"},
87
+ "locationProv": location_prov,
88
+ })
89
+
90
+ # ── API key management ────────────────────────────────────────
91
+
92
+ def create_api_key(self, name: str, scope: str, consumption_limit: float | None = None) -> dict:
93
+ params = {"name": name, "scope": scope}
94
+ if consumption_limit is not None:
95
+ params["consumptionLimit"] = str(consumption_limit)
96
+ return self._post("/v2/apikeys", params=params)
97
+
98
+ def list_api_keys(self) -> dict:
99
+ return self._get("/v2/apikeys")
100
+
101
+ def delete_api_key(self, key_id: str) -> dict:
102
+ return self._delete(f"/v2/apikeys/{key_id}")
103
+
104
+ # ── Internal ──────────────────────────────────────────────────
105
+
106
+ def _ensure_token(self) -> str:
107
+ token = self._config.get_cached_token()
108
+ if token:
109
+ return token
110
+ agent_id = self._config.get("agent_id")
111
+ api_key = self._config.get("api_key")
112
+ if agent_id and api_key:
113
+ self.login(agent_id, api_key)
114
+ token = self._config.get_cached_token()
115
+ if token:
116
+ return token
117
+ raise ConfigError(
118
+ "No valid token. Run 'snaplii init --agent-id ID --api-key KEY' to authenticate."
119
+ )
120
+
121
+ def _get(self, path: str, params: dict | None = None) -> dict:
122
+ token = self._ensure_token()
123
+ url = self._base_url + path
124
+ headers = {"Authorization": f"Bearer {token}"}
125
+ try:
126
+ resp = self._http.get(url, params=params, headers=headers)
127
+ except httpx.ConnectError as e:
128
+ raise GatewayConnectionError(url, e)
129
+ return self._parse_response(resp, path)
130
+
131
+ def _post(self, path: str, json: dict | None = None, params: dict | None = None) -> dict:
132
+ url = self._base_url + path
133
+ headers = {}
134
+ if path != "/v2/auth/token":
135
+ token = self._ensure_token()
136
+ headers = {"Authorization": f"Bearer {token}"}
137
+ try:
138
+ resp = self._http.post(url, json=json, params=params, headers=headers)
139
+ except httpx.ConnectError as e:
140
+ raise GatewayConnectionError(url, e)
141
+ return self._parse_response(resp, path)
142
+
143
+ def _delete(self, path: str) -> dict:
144
+ token = self._ensure_token()
145
+ url = self._base_url + path
146
+ headers = {"Authorization": f"Bearer {token}"}
147
+ try:
148
+ resp = self._http.delete(url, headers=headers)
149
+ except httpx.ConnectError as e:
150
+ raise GatewayConnectionError(url, e)
151
+ return self._parse_response(resp, path)
152
+
153
+ # Human-readable error messages for common error codes
154
+ _ERROR_MESSAGES = {
155
+ "MACP6005": "Payment failed. This usually means insufficient Snaplii Cash balance. Please top up your Snaplii Cash and try again.",
156
+ "MACP6006": "Service call failed. The downstream gift card service is temporarily unavailable. Please try again later.",
157
+ "MCAP9999": "Session expired. Please run 'snaplii init' to re-authenticate.",
158
+ "MCA20101": "Invalid API key format or request parameters.",
159
+ "MCA20102": "This API key has been deactivated.",
160
+ "MCA20103": "An API key with this name already exists. Please choose a different name.",
161
+ "MCA20104": "API key limit reached. Delete an existing key before creating a new one.",
162
+ "MCA20105": "API key not found.",
163
+ "MCA20106": "This API key does not belong to your account.",
164
+ "APP_VERSION_NOT_SUPPORT": "App version too low. Minimum version 4.8.0 required.",
165
+ "USR_NOT_EXIST": "User not found in session. Please re-authenticate.",
166
+ "ORDER_STATUS_INCORRECT": "Order status error. The order may not exist or is not in a payable state.",
167
+ "ORDER_CREATION_FAILED": "Order creation failed. You may have reached a spending limit.",
168
+ "AUTH_VERIFY_FAILED": "Authentication verification failed.",
169
+ }
170
+
171
+ @classmethod
172
+ def _parse_response(cls, resp: httpx.Response, path: str):
173
+ try:
174
+ body = resp.json()
175
+ except Exception:
176
+ body = {"raw": resp.text}
177
+ if resp.is_success:
178
+ if isinstance(body, dict):
179
+ rsp_code = body.get("rspMsgCd", "")
180
+ if rsp_code and not rsp_code.endswith("00000"):
181
+ friendly = cls._ERROR_MESSAGES.get(rsp_code)
182
+ if friendly:
183
+ body["friendly_message"] = friendly
184
+ raise GatewayApiError(resp.status_code, body, path)
185
+ return body
186
+ if not isinstance(body, dict):
187
+ body = {"raw": body}
188
+ raise GatewayApiError(resp.status_code, body, path)
File without changes
@@ -0,0 +1,42 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("apikey")
8
+ @click.pass_context
9
+ def apikey_group(ctx):
10
+ """API key management (create, list, delete)."""
11
+ pass
12
+
13
+
14
+ @apikey_group.command("list")
15
+ @click.pass_context
16
+ def apikey_list(ctx):
17
+ """List all your API keys."""
18
+ client: GatewayClient = ctx.obj["client"]
19
+ resp = client.list_api_keys()
20
+ print_json(resp)
21
+
22
+
23
+ @apikey_group.command("create")
24
+ @click.option("--name", required=True, help="API key name")
25
+ @click.option("--scope", default="PAY_READ", help="Scope: PAY_READ or PAY_WRITE")
26
+ @click.option("--limit", default=None, type=float, help="Consumption limit in dollars")
27
+ @click.pass_context
28
+ def apikey_create(ctx, name, scope, limit):
29
+ """Create a new API key."""
30
+ client: GatewayClient = ctx.obj["client"]
31
+ resp = client.create_api_key(name, scope, limit)
32
+ print_json(resp)
33
+
34
+
35
+ @apikey_group.command("delete")
36
+ @click.option("--key-id", required=True, help="API key ID to delete")
37
+ @click.pass_context
38
+ def apikey_delete(ctx, key_id):
39
+ """Delete an API key."""
40
+ client: GatewayClient = ctx.obj["client"]
41
+ resp = client.delete_api_key(key_id)
42
+ print_json(resp)
@@ -0,0 +1,32 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("browse")
8
+ @click.pass_context
9
+ def browse_group(ctx):
10
+ """Browse available gift card brands and categories."""
11
+ pass
12
+
13
+
14
+ @browse_group.command("tags")
15
+ @click.option("--channel", default="HOME_PAGE", help="Channel: HOME_PAGE or SEND_GIFT")
16
+ @click.option("--prov", default="ON", help="Province code (ON, QC, BC, etc.)")
17
+ @click.pass_context
18
+ def browse_tags(ctx, channel, prov):
19
+ """List all card categories (tags) with brand summaries."""
20
+ client: GatewayClient = ctx.obj["client"]
21
+ resp = client.get_all_card_tags(channel=channel, location_prov=prov)
22
+ print_json(resp)
23
+
24
+
25
+ @browse_group.command("brand")
26
+ @click.option("--id", "brand_id", required=True, help="Card brand ID (e.g. CB0000000000135)")
27
+ @click.pass_context
28
+ def browse_brand(ctx, brand_id):
29
+ """Get card brand details including available denominations."""
30
+ client: GatewayClient = ctx.obj["client"]
31
+ resp = client.get_card_brand_by_id(brand_id)
32
+ print_json(resp)
@@ -0,0 +1,30 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("card-brands")
8
+ @click.pass_context
9
+ def card_brands_group(ctx):
10
+ """Card brand operations (list, get)."""
11
+ pass
12
+
13
+
14
+ @card_brands_group.command("list")
15
+ @click.pass_context
16
+ def card_brands_list(ctx):
17
+ """List all available card brands (brandId + name)."""
18
+ client: GatewayClient = ctx.obj["client"]
19
+ resp = client.list_card_brands()
20
+ print_json(resp)
21
+
22
+
23
+ @card_brands_group.command("get")
24
+ @click.option("--id", "card_brand_id", required=True, help="Card brand ID")
25
+ @click.pass_context
26
+ def card_brands_get(ctx, card_brand_id):
27
+ """Get card brand details by ID."""
28
+ client: GatewayClient = ctx.obj["client"]
29
+ resp = client.get_card_brand(card_brand_id)
30
+ print_json(resp)
@@ -0,0 +1,44 @@
1
+ import click
2
+
3
+ from snaplii.config_store import ConfigStore
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("config")
8
+ @click.pass_context
9
+ def config_group(ctx):
10
+ """Manage CLI configuration (base URL, credentials)."""
11
+ pass
12
+
13
+
14
+ @config_group.command("set")
15
+ @click.option("--base-url", required=True, help="Gateway base URL (e.g. http://localhost:8080)")
16
+ @click.pass_context
17
+ def config_set(ctx, base_url):
18
+ """Set the gateway base URL."""
19
+ store: ConfigStore = ctx.obj["config_store"]
20
+ store.set("base_url", base_url)
21
+ print_json({"status": "ok", "updated": ["base_url"]})
22
+
23
+
24
+ @config_group.command("show")
25
+ @click.pass_context
26
+ def config_show(ctx):
27
+ """Display current configuration."""
28
+ store: ConfigStore = ctx.obj["config_store"]
29
+ data = store.load()
30
+ if "api_key" in data and data["api_key"]:
31
+ key = data["api_key"]
32
+ data["api_key"] = key[:8] + "..." if len(key) > 8 else "***"
33
+ if "access_token" in data and data["access_token"]:
34
+ data["access_token"] = data["access_token"][:20] + "..."
35
+ print_json(data)
36
+
37
+
38
+ @config_group.command("clear")
39
+ @click.pass_context
40
+ def config_clear(ctx):
41
+ """Delete configuration file."""
42
+ store: ConfigStore = ctx.obj["config_store"]
43
+ store.clear()
44
+ print_json({"status": "ok", "message": "Configuration cleared"})
@@ -0,0 +1,33 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("giftcard")
8
+ @click.pass_context
9
+ def giftcard_group(ctx):
10
+ """Gift card operations (list owned cards, view details)."""
11
+ pass
12
+
13
+
14
+ @giftcard_group.command("list")
15
+ @click.option("--status", default="ACTIVE", help="Card status: ACTIVE or INACTIVE")
16
+ @click.option("--page", default=1, type=int, help="Page number")
17
+ @click.option("--page-size", default=20, type=int, help="Page size")
18
+ @click.pass_context
19
+ def giftcard_list(ctx, status, page, page_size):
20
+ """List your gift cards."""
21
+ client: GatewayClient = ctx.obj["client"]
22
+ resp = client.list_user_cards(status=status, page=page, page_size=page_size)
23
+ print_json(resp)
24
+
25
+
26
+ @giftcard_group.command("detail")
27
+ @click.option("--card-no", required=True, help="Card number")
28
+ @click.pass_context
29
+ def giftcard_detail(ctx, card_no):
30
+ """Get details of a specific gift card (including redemption code)."""
31
+ client: GatewayClient = ctx.obj["client"]
32
+ resp = client.get_card_detail(card_no)
33
+ print_json(resp)
@@ -0,0 +1,18 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.command("init")
8
+ @click.option("--agent-id", required=True, help="Agent ID for session isolation")
9
+ @click.option("--api-key", required=True, help="API key (snp_sk_live_...)")
10
+ @click.pass_context
11
+ def init_cmd(ctx, agent_id, api_key):
12
+ """Login with agent ID + API key and store credentials."""
13
+ client: GatewayClient = ctx.obj["client"]
14
+ store = ctx.obj["config_store"]
15
+
16
+ store.set_many({"agent_id": agent_id, "api_key": api_key})
17
+ resp = client.login(agent_id, api_key)
18
+ print_json(resp)
@@ -0,0 +1,24 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.command("purchase")
8
+ @click.option("--item-id", required=True, help="Item ID (e.g. CB0000000000135-CT0000000000897)")
9
+ @click.option("--price", required=True, help="Price in dollars (e.g. 50)")
10
+ @click.option("--payment-method", default="SNAPLII_CASH", help="Payment method (SNAPLII_CASH, SNAPLII_CREDIT, SNAPLII_DEBIT)")
11
+ @click.option("--payment-token", default=None, help="Payment token (auto-derived by gateway if omitted)")
12
+ @click.option("--prov", default="ON", help="Province code")
13
+ @click.pass_context
14
+ def purchase_cmd(ctx, item_id, price, payment_method, payment_token, prov):
15
+ """Create an order and pay for a gift card."""
16
+ client: GatewayClient = ctx.obj["client"]
17
+ resp = client.create_order_and_pay(
18
+ item_id=item_id,
19
+ price=price,
20
+ payment_method=payment_method,
21
+ payment_token=payment_token,
22
+ location_prov=prov,
23
+ )
24
+ print_json(resp)
@@ -0,0 +1,81 @@
1
+ import click
2
+
3
+ from snaplii.client import GatewayClient
4
+ from snaplii.output import print_json
5
+
6
+
7
+ @click.group("smart")
8
+ @click.pass_context
9
+ def smart_group(ctx):
10
+ """Smart features: cashback calculator, dashboard."""
11
+ pass
12
+
13
+
14
+ @smart_group.command("cashback")
15
+ @click.option("--brand-id", required=True, help="Card brand ID")
16
+ @click.option("--amount", required=True, type=float, help="Purchase amount in dollars")
17
+ @click.pass_context
18
+ def cashback_cmd(ctx, brand_id, amount):
19
+ """Calculate cashback savings for a specific brand and amount."""
20
+ client: GatewayClient = ctx.obj["client"]
21
+ detail = client.get_card_brand_by_id(brand_id)
22
+ # Gateway returns detail directly; prod wraps in "data"
23
+ brand_data = detail.get("data", detail) if isinstance(detail.get("data"), dict) else detail
24
+ cards = brand_data.get("cards", [])
25
+
26
+ best_match = None
27
+ for c in cards:
28
+ fv = c.get("faceValueRules", {})
29
+ if fv.get("type") == "FIXED" and float(fv.get("priceStart", 0)) == amount:
30
+ best_match = c
31
+ break
32
+ elif fv.get("type") == "VARIABLE":
33
+ start = float(fv.get("priceStart", 0))
34
+ end = float(fv.get("priceEnd", 0))
35
+ if start <= amount <= end:
36
+ best_match = c
37
+
38
+ if best_match:
39
+ discount_pct = float(best_match.get("discount", 0) or 0)
40
+ savings = amount * discount_pct / 100
41
+ print_json({
42
+ "brand_id": brand_id,
43
+ "amount": amount,
44
+ "cashback_percent": f"{discount_pct}%",
45
+ "you_save": f"${savings:.2f}",
46
+ "effective_cost": f"${amount - savings:.2f}",
47
+ "item_id": f"{brand_id}-{best_match.get('cardTemplateId', '')}",
48
+ })
49
+ else:
50
+ print_json({"error": f"No matching denomination for ${amount}"})
51
+
52
+
53
+ @smart_group.command("dashboard")
54
+ @click.pass_context
55
+ def dashboard_cmd(ctx):
56
+ """Show a summary dashboard of all your gift cards."""
57
+ client: GatewayClient = ctx.obj["client"]
58
+ resp = client.list_user_cards(status="ACTIVE", page=1, page_size=100)
59
+ cards = resp.get("data", [])
60
+
61
+ total_value = 0
62
+ brands = {}
63
+ for card in cards:
64
+ face_value = float(card.get("faceValue", 0))
65
+ total_value += face_value
66
+ brand_id = card.get("cardBrandId", "unknown")
67
+ brand_name = card.get("cardTemplate", {}).get("desc", {}).get("name", brand_id)
68
+ if brands.get(brand_id):
69
+ brands[brand_id]["count"] += 1
70
+ brands[brand_id]["total"] += face_value
71
+ else:
72
+ brands[brand_id] = {"name": brand_name, "count": 1, "total": face_value}
73
+
74
+ print_json({
75
+ "total_cards": len(cards),
76
+ "total_face_value": f"${total_value:.2f}",
77
+ "brands": [
78
+ {"brand": info["name"], "cards": info["count"], "total_value": f"${info['total']:.2f}"}
79
+ for info in sorted(brands.values(), key=lambda x: x["total"], reverse=True)
80
+ ],
81
+ })
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ _TOKEN_SAFETY_MARGIN = 30 # seconds before expiry to trigger refresh
11
+
12
+
13
+ class ConfigStore:
14
+ def __init__(self, path: Path | None = None):
15
+ self._path = path or Path.home() / ".snaplii" / "config.json"
16
+
17
+ @property
18
+ def path(self) -> Path:
19
+ return self._path
20
+
21
+ def load(self) -> dict:
22
+ if not self._path.exists():
23
+ return {}
24
+ return json.loads(self._path.read_text())
25
+
26
+ def save(self, data: dict) -> None:
27
+ self._path.parent.mkdir(parents=True, exist_ok=True)
28
+ self._path.write_text(json.dumps(data, indent=2) + "\n")
29
+ os.chmod(self._path, 0o600)
30
+
31
+ def get(self, key: str, default: Any = None) -> Any:
32
+ return self.load().get(key, default)
33
+
34
+ def set(self, key: str, value: Any) -> None:
35
+ data = self.load()
36
+ data[key] = value
37
+ self.save(data)
38
+
39
+ def set_many(self, updates: dict) -> None:
40
+ data = self.load()
41
+ data.update(updates)
42
+ self.save(data)
43
+
44
+ def clear(self) -> None:
45
+ if self._path.exists():
46
+ self._path.unlink()
47
+
48
+ def get_cached_token(self) -> str | None:
49
+ data = self.load()
50
+ token = data.get("access_token")
51
+ expires_at = data.get("token_expires_at")
52
+ if not token or not expires_at:
53
+ return None
54
+ if time.time() >= expires_at - _TOKEN_SAFETY_MARGIN:
55
+ return None
56
+ return token
57
+
58
+ def cache_token(self, access_token: str, expires_in: int) -> None:
59
+ self.set_many({
60
+ "access_token": access_token,
61
+ "token_expires_at": time.time() + expires_in,
62
+ })
63
+
@@ -0,0 +1,41 @@
1
+ class SnapliiCliError(Exception):
2
+ pass
3
+
4
+
5
+ class GatewayApiError(SnapliiCliError):
6
+ def __init__(self, status_code: int, body: dict, endpoint: str):
7
+ self.status_code = status_code
8
+ self.body = body
9
+ self.endpoint = endpoint
10
+ super().__init__(f"API error {status_code} on {endpoint}")
11
+
12
+ def to_dict(self) -> dict:
13
+ friendly = self.body.get("friendly_message")
14
+ return {
15
+ "error": friendly or "API error",
16
+ "error_code": self.body.get("rspMsgCd", ""),
17
+ "endpoint": self.endpoint,
18
+ }
19
+
20
+
21
+ class GatewayConnectionError(SnapliiCliError):
22
+ def __init__(self, url: str, cause: Exception):
23
+ self.url = url
24
+ self.cause = cause
25
+ super().__init__(f"Connection failed: {url}")
26
+
27
+ def to_dict(self) -> dict:
28
+ return {
29
+ "error": "Connection failed",
30
+ "url": self.url,
31
+ "cause": str(self.cause),
32
+ }
33
+
34
+
35
+ class ConfigError(SnapliiCliError):
36
+ def __init__(self, message: str):
37
+ self.message = message
38
+ super().__init__(message)
39
+
40
+ def to_dict(self) -> dict:
41
+ return {"error": "Configuration error", "message": self.message}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+
7
+ def print_json(data: dict | list) -> None:
8
+ print(json.dumps(data, indent=2))
9
+
10
+
11
+ def print_error(err_dict: dict) -> None:
12
+ print(json.dumps(err_dict, indent=2), file=sys.stderr)