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.
- snaplii_cli-0.4.0/.gitignore +8 -0
- snaplii_cli-0.4.0/PKG-INFO +22 -0
- snaplii_cli-0.4.0/pyproject.toml +44 -0
- snaplii_cli-0.4.0/src/snaplii/__init__.py +1 -0
- snaplii_cli-0.4.0/src/snaplii/__main__.py +3 -0
- snaplii_cli-0.4.0/src/snaplii/cli.py +67 -0
- snaplii_cli-0.4.0/src/snaplii/client.py +188 -0
- snaplii_cli-0.4.0/src/snaplii/commands/__init__.py +0 -0
- snaplii_cli-0.4.0/src/snaplii/commands/apikey.py +42 -0
- snaplii_cli-0.4.0/src/snaplii/commands/browse.py +32 -0
- snaplii_cli-0.4.0/src/snaplii/commands/card_brands.py +30 -0
- snaplii_cli-0.4.0/src/snaplii/commands/config.py +44 -0
- snaplii_cli-0.4.0/src/snaplii/commands/giftcard.py +33 -0
- snaplii_cli-0.4.0/src/snaplii/commands/init.py +18 -0
- snaplii_cli-0.4.0/src/snaplii/commands/purchase.py +24 -0
- snaplii_cli-0.4.0/src/snaplii/commands/smart.py +81 -0
- snaplii_cli-0.4.0/src/snaplii/config_store.py +63 -0
- snaplii_cli-0.4.0/src/snaplii/exceptions.py +41 -0
- snaplii_cli-0.4.0/src/snaplii/output.py +12 -0
|
@@ -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,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}
|