makea-cli 0.2.13__tar.gz → 0.2.15__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.
Files changed (69) hide show
  1. {makea_cli-0.2.13 → makea_cli-0.2.15}/CHANGELOG.md +36 -0
  2. {makea_cli-0.2.13 → makea_cli-0.2.15}/PKG-INFO +3 -3
  3. {makea_cli-0.2.13 → makea_cli-0.2.15}/README.md +2 -2
  4. makea_cli-0.2.15/makea_cli/__init__.py +1 -0
  5. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/api_client.py +14 -6
  6. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/metrics/cmd.py +27 -65
  7. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/orders/cmd.py +7 -5
  8. {makea_cli-0.2.13 → makea_cli-0.2.15}/pyproject.toml +1 -1
  9. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/golden_manifest.json +6 -6
  10. makea_cli-0.2.15/tests/test_currency_conversion.py +106 -0
  11. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_pm_reads.py +14 -3
  12. makea_cli-0.2.13/makea_cli/__init__.py +0 -1
  13. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/create-supplier-profile/SKILL.md +0 -0
  14. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/lark-cli-setup/SKILL.md +0 -0
  15. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/link-supplier-user/SKILL.md +0 -0
  16. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/makea-cli-setup/SKILL.md +0 -0
  17. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/payment-revenue-report/SKILL.md +0 -0
  18. {makea_cli-0.2.13 → makea_cli-0.2.15}/.claude/skills/supplier-user-id-migration/SKILL.md +0 -0
  19. {makea_cli-0.2.13 → makea_cli-0.2.15}/.gitignore +0 -0
  20. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea-collection-upload/SKILL.md +0 -0
  21. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea-collection-upload/scripts/create_products.py +0 -0
  22. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/__main__.py +0 -0
  23. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/auth_pkce.py +0 -0
  24. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/broker.py +0 -0
  25. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/__init__.py +0 -0
  26. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/_util.py +0 -0
  27. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/analytics/__init__.py +0 -0
  28. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/analytics/cmd.py +0 -0
  29. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/auth/__init__.py +0 -0
  30. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/auth/cmd.py +0 -0
  31. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/brand/__init__.py +0 -0
  32. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/brand/cmd.py +0 -0
  33. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/designer/__init__.py +0 -0
  34. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/designer/cmd.py +0 -0
  35. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/directory/__init__.py +0 -0
  36. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/directory/cmd.py +0 -0
  37. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/document/__init__.py +0 -0
  38. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/document/cmd.py +0 -0
  39. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/email/__init__.py +0 -0
  40. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/email/cmd.py +0 -0
  41. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/factory/__init__.py +0 -0
  42. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/factory/cmd.py +0 -0
  43. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/library/__init__.py +0 -0
  44. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/library/cmd.py +0 -0
  45. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/metrics/__init__.py +0 -0
  46. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/orders/__init__.py +0 -0
  47. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/product/__init__.py +0 -0
  48. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/product/cmd.py +0 -0
  49. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/production/__init__.py +0 -0
  50. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/production/cmd.py +0 -0
  51. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/production/pricing_spec.py +0 -0
  52. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/supplier/__init__.py +0 -0
  53. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/commands/supplier/cmd.py +0 -0
  54. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/config.py +0 -0
  55. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/errors.py +0 -0
  56. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/identity.py +0 -0
  57. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/main.py +0 -0
  58. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/output.py +0 -0
  59. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/posthog_client.py +0 -0
  60. {makea_cli-0.2.13 → makea_cli-0.2.15}/makea_cli/registry.py +0 -0
  61. {makea_cli-0.2.13 → makea_cli-0.2.15}/npm/makea-cli/README.md +0 -0
  62. {makea_cli-0.2.13 → makea_cli-0.2.15}/npm/makea-cli/makea-cli.mjs +0 -0
  63. {makea_cli-0.2.13 → makea_cli-0.2.15}/npm/makea-cli/package.json +0 -0
  64. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_broker.py +0 -0
  65. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_output_contract.py +0 -0
  66. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_persona_commands.py +0 -0
  67. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_registry.py +0 -0
  68. {makea_cli-0.2.13 → makea_cli-0.2.15}/tests/test_supplier_scorecard.py +0 -0
  69. {makea_cli-0.2.13 → makea_cli-0.2.15}/uv.lock +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.15
4
+
5
+ **Behaviour change: `admin get currency-conversion` returns different
6
+ numbers.** This is not only a bug fix. Anyone who reconciled figures with
7
+ this command should expect them to move, by up to several percent per
8
+ currency. The same call on 2026-10-16:
9
+
10
+ | GBP per | 0.2.14 | 0.2.15 | 1,000 converted, 0.2.14 → 0.2.15 |
11
+ |---|---|---|---|
12
+ | CNY | 0.12 | 0.113 | 120.00 → 113.00 (−5.8%) |
13
+ | USD | 0.76 | 0.752 | 760.00 → 752.00 |
14
+ | EUR | 0.85 | 0.848 | 850.00 → 848.00 |
15
+
16
+ What changed:
17
+
18
+ - Rates now come from the platform (`GET /admin/fx/rates`, backend
19
+ 2026-10-16) instead of the CLI fetching the free FX source itself. They are
20
+ rounded **up to 3 decimals**, as the platform rounds them; 0.2.14 rounded
21
+ up to 2, which overstated cheap currencies such as CNY most.
22
+ - With one `--from` and `--amount`, `converted` is the platform's own
23
+ conversion, rounded **up** to the cent; 0.2.14 rounded to the nearest cent.
24
+ - So the numbers now match the platform's prices and revenue metrics, which
25
+ the 0.2.14 ones did not.
26
+ - Output keys are unchanged except `rounding` (now `"up"`, was
27
+ `"ceil_2dp"`), plus new `rate_decimals` and `unavailable`. A currency with
28
+ no rate still fails the command. Needs an admin login, like every `admin`
29
+ command.
30
+
31
+ ## 0.2.14
32
+
33
+ - `admin update tracking-status`: help text no longer describes it as "the
34
+ 17track webhook path". The 17track integration (registration and webhook)
35
+ was removed from the backend on 2026-10-10; this command, fed by the
36
+ tracking-sweep skill, is now the only way a carrier status reaches an
37
+ order. Behaviour is unchanged.
38
+
3
39
  ## 0.2.13
4
40
 
5
41
  - `designer get supplier-scorecard [SUPPLIER_ID]`: a factory's track record
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: makea-cli
3
- Version: 0.2.13
3
+ Version: 0.2.15
4
4
  Summary: Makea admin CLI (browser login + Makea admin HTTP API)
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click<9,>=8.1
@@ -100,7 +100,7 @@ exit code:`0` 成功 · `2` 用法错 · `3` 未登录或身份与 persona 不
100
100
  | `makea-cli admin generate pricing-groups` | write(ask) | `POST /admin/production_orders/{id}/details/update` | Lay a production order's variants into pricing groups along one axis (--by style \| colour \| variant \| all) with th… | `production-groups-generate` |
101
101
  | `makea-cli admin get active-users` | read | `POST /admin/analytics/query` | Distinct active users per day over the last N days (DAU series). | `analytics-active-users` |
102
102
  | `makea-cli admin get analytics-query` | read | `POST /admin/analytics/query` | Run an arbitrary HogQL query (escape hatch). Example: analytics-query "SELECT count() FROM events" | `analytics-query` |
103
- | `makea-cli admin get currency-conversion` | read | `currency-api` | avoids repeated lookups. | `convert-currency` |
103
+ | `makea-cli admin get currency-conversion` | read | `GET /admin/fx/rates` | the platform's own exchange rates and rounding (rate table for several --from; one --from with --amount converts it). | `convert-currency` |
104
104
  | `makea-cli admin get feature-usage` | read | `POST /admin/analytics/query` | How many distinct users used a feature recently, plus event volume. Matches $pageview path and feature_time_spent eve… | `analytics-feature-usage` |
105
105
  | `makea-cli admin get library-schema` | read | `GET /admin/library_components/schema` | the tech-pack library taxonomy: sections and their library-role field keys (run this before create/update). | `library-schema` |
106
106
  | `makea-cli admin get production-pricing` | read | `GET /admin/production_orders/{id}/details` | show a production order's price tiers, MOQ, sizes/colours, pricing groups and cart coverage. | `production-pricing-show` |
@@ -131,7 +131,7 @@ exit code:`0` 成功 · `2` 用法错 · `3` 未登录或身份与 persona 不
131
131
  | `makea-cli admin update quote-request` | write(ask) | `PATCH /admin/quote_requests/{quote_request_id}/update` | set a quote request's stage, status and/or due date. Only what you pass is changed. This is the door that unblocks a … | `update-quote-request` |
132
132
  | `makea-cli admin update supplier` | write(ask) | `PUT /admin/supplier/update_supplier` | update an existing supplier from a JSON file (admin). Run `makea-cli get-supplier-profile-schema` FIRST to get the fi… | `update-supplier` |
133
133
  | `makea-cli admin update tech-spec` | write(ask) | `POST /admin/products/{id}/technical-specifications` | persist specs from a JSON file. | `save-product-tech-spec` |
134
- | `makea-cli admin update tracking-status` | write(ask) | `POST /admin/tracking/carrier_status` | apply a carrier status looked up by hand (tracking-sweep skill) to one order. Runs the 17track webhook path: status, fan-out, delivered emails (--silent: state only). | — |
134
+ | `makea-cli admin update tracking-status` | write(ask) | `POST /admin/tracking/carrier_status` | apply a carrier status looked up by hand (tracking-sweep skill) to one order: status, fan-out, delivered emails (--silent: state only). | — |
135
135
  | `makea-cli admin update user` | write(ask) | `PUT /admin/user-profiles/{user_id}/update` | admin updates DynamoDB user profile fields and/or Cognito custom:userType. Allowed --user-type values: "Fashion brand… | `update-user-profile` |
136
136
  | `makea-cli admin upload financial-document` | write(auto) | `POST /admin/products/{product_id}/financial_documents` | upload one or more files to AWS S3 and return financial document metadata. | `upload-financial-documents` |
137
137
  | `makea-cli admin upload link-document` | write(auto) | `POST /admin/upload_link_document` | upload a file to AWS S3 and return document domain metadata for reuse as input in other commands. | `upload-link-document` |
@@ -88,7 +88,7 @@ exit code:`0` 成功 · `2` 用法错 · `3` 未登录或身份与 persona 不
88
88
  | `makea-cli admin generate pricing-groups` | write(ask) | `POST /admin/production_orders/{id}/details/update` | Lay a production order's variants into pricing groups along one axis (--by style \| colour \| variant \| all) with th… | `production-groups-generate` |
89
89
  | `makea-cli admin get active-users` | read | `POST /admin/analytics/query` | Distinct active users per day over the last N days (DAU series). | `analytics-active-users` |
90
90
  | `makea-cli admin get analytics-query` | read | `POST /admin/analytics/query` | Run an arbitrary HogQL query (escape hatch). Example: analytics-query "SELECT count() FROM events" | `analytics-query` |
91
- | `makea-cli admin get currency-conversion` | read | `currency-api` | avoids repeated lookups. | `convert-currency` |
91
+ | `makea-cli admin get currency-conversion` | read | `GET /admin/fx/rates` | the platform's own exchange rates and rounding (rate table for several --from; one --from with --amount converts it). | `convert-currency` |
92
92
  | `makea-cli admin get feature-usage` | read | `POST /admin/analytics/query` | How many distinct users used a feature recently, plus event volume. Matches $pageview path and feature_time_spent eve… | `analytics-feature-usage` |
93
93
  | `makea-cli admin get library-schema` | read | `GET /admin/library_components/schema` | the tech-pack library taxonomy: sections and their library-role field keys (run this before create/update). | `library-schema` |
94
94
  | `makea-cli admin get production-pricing` | read | `GET /admin/production_orders/{id}/details` | show a production order's price tiers, MOQ, sizes/colours, pricing groups and cart coverage. | `production-pricing-show` |
@@ -119,7 +119,7 @@ exit code:`0` 成功 · `2` 用法错 · `3` 未登录或身份与 persona 不
119
119
  | `makea-cli admin update quote-request` | write(ask) | `PATCH /admin/quote_requests/{quote_request_id}/update` | set a quote request's stage, status and/or due date. Only what you pass is changed. This is the door that unblocks a … | `update-quote-request` |
120
120
  | `makea-cli admin update supplier` | write(ask) | `PUT /admin/supplier/update_supplier` | update an existing supplier from a JSON file (admin). Run `makea-cli get-supplier-profile-schema` FIRST to get the fi… | `update-supplier` |
121
121
  | `makea-cli admin update tech-spec` | write(ask) | `POST /admin/products/{id}/technical-specifications` | persist specs from a JSON file. | `save-product-tech-spec` |
122
- | `makea-cli admin update tracking-status` | write(ask) | `POST /admin/tracking/carrier_status` | apply a carrier status looked up by hand (tracking-sweep skill) to one order. Runs the 17track webhook path: status, fan-out, delivered emails (--silent: state only). | — |
122
+ | `makea-cli admin update tracking-status` | write(ask) | `POST /admin/tracking/carrier_status` | apply a carrier status looked up by hand (tracking-sweep skill) to one order: status, fan-out, delivered emails (--silent: state only). | — |
123
123
  | `makea-cli admin update user` | write(ask) | `PUT /admin/user-profiles/{user_id}/update` | admin updates DynamoDB user profile fields and/or Cognito custom:userType. Allowed --user-type values: "Fashion brand… | `update-user-profile` |
124
124
  | `makea-cli admin upload financial-document` | write(auto) | `POST /admin/products/{product_id}/financial_documents` | upload one or more files to AWS S3 and return financial document metadata. | `upload-financial-documents` |
125
125
  | `makea-cli admin upload link-document` | write(auto) | `POST /admin/upload_link_document` | upload a file to AWS S3 and return document domain metadata for reuse as input in other commands. | `upload-link-document` |
@@ -0,0 +1 @@
1
+ __version__ = "0.2.15"
@@ -844,6 +844,18 @@ def get_cumulative_payment_and_platform_revenue(
844
844
  )
845
845
 
846
846
 
847
+ def get_fx_rates(*, to: str, sources: list[str], amount: float | None = None) -> Any:
848
+ """
849
+ GET /admin/fx/rates — the platform's own exchange rates (rounded up to its
850
+ RATE_DECIMALS), and with one source and an amount, that amount converted the
851
+ way the platform converts it.
852
+ """
853
+ params: dict[str, Any] = {"to": to, "from": ",".join(sources)}
854
+ if amount is not None:
855
+ params["amount"] = amount
856
+ return get_json("/admin/fx/rates", params=params)
857
+
858
+
847
859
  def extract_ai_tech_specifications(product_reference_id: str, *, timeout: float = 300.0) -> Any:
848
860
  """
849
861
  GET /admin/products/{id}/extract-tech-specifications — synchronous AI extraction;
@@ -1576,9 +1588,7 @@ def designer_list_milestones(product_id: str) -> Any:
1576
1588
  params={"type": "PRODUCTION"},
1577
1589
  )
1578
1590
  orders = (
1579
- raw.get("production_orders_details") or raw.get("orders")
1580
- if isinstance(raw, dict)
1581
- else raw
1591
+ raw.get("production_orders_details") or raw.get("orders") if isinstance(raw, dict) else raw
1582
1592
  )
1583
1593
  out = []
1584
1594
  for order in orders or []:
@@ -1604,9 +1614,7 @@ def designer_list_milestones(product_id: str) -> Any:
1604
1614
  return {"product_id": product_id, "milestones": out}
1605
1615
 
1606
1616
 
1607
- def designer_list_notes(
1608
- product_id: str, channel_key: str, *, since: str | None = None
1609
- ) -> Any:
1617
+ def designer_list_notes(product_id: str, channel_key: str, *, since: str | None = None) -> Any:
1610
1618
  """A channel's messages, oldest first, optionally only those after
1611
1619
  ``since`` (ISO date or datetime): what the factory said this week."""
1612
1620
  raw = get_json(f"/designer/products/{product_id}/channels/{channel_key}/notes")
@@ -1,8 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import math
4
-
5
- import httpx
6
3
  import typer
7
4
  from rich.console import Console
8
5
 
@@ -16,38 +13,6 @@ console = Console()
16
13
  _GRANULARITIES = ("day", "week", "month")
17
14
  _ORDER_TYPES = ("all", "sampling", "production")
18
15
 
19
- # Same free FX source the backend uses (FreeCurrencyService), so converted
20
- # amounts match the platform's revenue metrics.
21
- _FX_PRIMARY = (
22
- "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{base}.json"
23
- )
24
- _FX_FALLBACK = "https://latest.currency-api.pages.dev/v1/currencies/{base}.json"
25
-
26
-
27
- def _fx_rate(base: str, target: str) -> float:
28
- """Exchange rate base -> target, rounded UP to 2dp exactly like the backend
29
- (math.ceil(raw * 100) / 100) so totals reconcile with revenue metrics."""
30
- base, target = base.lower(), target.lower()
31
- if base == target:
32
- return 1.0
33
- last_err: Exception | None = None
34
- for url in (_FX_PRIMARY.format(base=base), _FX_FALLBACK.format(base=base)):
35
- try:
36
- with httpx.Client(timeout=10) as client:
37
- resp = client.get(url)
38
- resp.raise_for_status()
39
- data = resp.json()
40
- raw = data.get(base, {}).get(target)
41
- if raw is not None:
42
- return math.ceil(float(raw) * 100) / 100
43
- except Exception as e: # noqa: BLE001 - try fallback URL
44
- last_err = e
45
- continue
46
- raise RuntimeError(
47
- f"FX rate {base.upper()}->{target.upper()} unavailable"
48
- + (f": {last_err}" if last_err else "")
49
- )
50
-
51
16
 
52
17
  def register(app: typer.Typer) -> None:
53
18
  @command(
@@ -141,13 +106,13 @@ def register(app: typer.Typer) -> None:
141
106
  "get",
142
107
  "currency-conversion",
143
108
  legacy="convert-currency",
144
- api="GET currency-api",
145
- external="currency-api",
109
+ api="GET /admin/fx/rates",
146
110
  help=(
147
- "Convert money between currencies using the same free FX source and "
148
- "rounding as the platform's revenue metrics. Pass several source "
149
- "currencies via --from (comma-separated) to get a full rate table in "
150
- "one call, then convert locally — avoids repeated lookups."
111
+ "GET /admin/fx/rates — convert money between currencies at the "
112
+ "platform's own rates and rounding, so totals reconcile with its "
113
+ "prices and revenue metrics. Pass several source currencies via --from "
114
+ "(comma-separated) to get a full rate table in one call, then convert "
115
+ "locally — avoids repeated lookups."
151
116
  ),
152
117
  )
153
118
  def convert_currency(
@@ -165,17 +130,20 @@ def register(app: typer.Typer) -> None:
165
130
  help="Optional amount to convert (only meaningful with a single --from).",
166
131
  ),
167
132
  ) -> None:
168
- """Return source->target rates (rounded like the backend), and optionally a
169
- converted amount.
133
+ """Return source->target rates, and optionally a converted amount, as the
134
+ platform computes them.
170
135
 
171
136
  Use it to collapse a mixed-currency set into one currency: collect the
172
137
  distinct currencies, call once with --from a,b,c to get every rate, then
173
- multiply locally. Rates use math.ceil(raw * 100) / 100, matching the
174
- platform's FreeCurrencyService, so totals reconcile with revenue metrics.
175
-
176
- Output JSON: {"to", "rates": {CUR: rate, ...}, "rounding", "source"} plus
177
- {"from", "rate", "amount", "converted"} when a single --from and --amount
178
- are given.
138
+ multiply locally. The rates are the platform's (FreeCurrencyService,
139
+ rounded up to ``rate_decimals``) and come from the backend rather than
140
+ being fetched and rounded here, so they cannot drift from it.
141
+
142
+ Output JSON: {"to", "rates": {CUR: rate, ...}, "rate_decimals",
143
+ "rounding", "source", "unavailable"} plus {"from", "rate", "amount",
144
+ "converted"} when a single --from and --amount are given; "converted" is
145
+ the platform's own conversion, rounding included. A currency with no
146
+ rate fails the command rather than coming back null.
179
147
  """
180
148
  sources = [s.strip() for s in from_.split(",") if s.strip()]
181
149
  if not sources:
@@ -183,22 +151,16 @@ def register(app: typer.Typer) -> None:
183
151
  raise typer.Exit(1)
184
152
 
185
153
  def _do() -> dict:
186
- rates = {s.upper(): _fx_rate(s, to) for s in sources}
187
- out: dict = {
188
- "to": to.upper(),
189
- "rates": rates,
190
- "rounding": "ceil_2dp",
191
- "source": "fawazahmed0/currency-api",
192
- }
193
- if amount is not None and len(sources) == 1:
194
- rate = rates[sources[0].upper()]
195
- out.update(
196
- {
197
- "from": sources[0].upper(),
198
- "rate": rate,
199
- "amount": amount,
200
- "converted": round(amount * rate, 2),
201
- }
154
+ payload = api_client.get_fx_rates(
155
+ to=to,
156
+ sources=sources,
157
+ amount=amount if len(sources) == 1 else None,
158
+ )
159
+ out = (payload or {}).get("result") or {}
160
+ if out.get("unavailable"):
161
+ raise RuntimeError(
162
+ "FX rate unavailable: "
163
+ + ", ".join(f"{s}->{to.upper()}" for s in out["unavailable"])
202
164
  )
203
165
  return out
204
166
 
@@ -323,14 +323,14 @@ def register(app: typer.Typer) -> None:
323
323
  api="POST /admin/tracking/carrier_status",
324
324
  side_effects=[
325
325
  "moves the order to delivered when the status says so",
326
- "emails and messages the designer, supplier and admins as the 17track webhook did (none with --silent)",
326
+ "emails and messages the designer, supplier and admins that the parcel arrived (none with --silent)",
327
327
  ],
328
328
  approval="ask",
329
329
  idempotent=True,
330
330
  help=(
331
331
  "POST /admin/tracking/carrier_status — apply a carrier status looked up by hand "
332
- "(the tracking-sweep skill) to one order. Runs the 17track webhook path: status, "
333
- "fan-out to every order sharing the number, and the delivered emails."
332
+ "(the tracking-sweep skill) to one order: status, fan-out to every order "
333
+ "sharing the number, and the delivered emails."
334
334
  ),
335
335
  )
336
336
  def update_tracking_status(
@@ -341,7 +341,9 @@ def register(app: typer.Typer) -> None:
341
341
  order_id: str = typer.Argument(..., help="Order id (product id for reference_sample)."),
342
342
  tracking_number: str = typer.Argument(..., help="The parcel's tracking number."),
343
343
  status: str = typer.Option(
344
- ..., "--status", help="17track status, e.g. Delivered, InTransit."
344
+ ...,
345
+ "--status",
346
+ help="Carrier status as the public 17track page shows it, e.g. Delivered, InTransit.",
345
347
  ),
346
348
  sub_status: str | None = typer.Option(None, "--sub-status"),
347
349
  event: str | None = typer.Option(None, "--event", help="Latest carrier event text."),
@@ -358,7 +360,7 @@ def register(app: typer.Typer) -> None:
358
360
  """
359
361
  Tell Makea what the carrier says about one parcel.
360
362
 
361
- Output: JSON from the webhook path: success, status, and one entry per
363
+ Output: JSON: success, status, and one entry per
362
364
  order the number was fanned out to. Repeating a Delivered update is a
363
365
  no-op (no second email). --silent moves the orders without telling
364
366
  anyone.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "makea-cli"
7
- version = "0.2.13"
7
+ version = "0.2.15"
8
8
  description = "Makea admin CLI (browser login + Makea admin HTTP API)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -980,8 +980,8 @@
980
980
  "verb": "get",
981
981
  "noun": "currency-conversion",
982
982
  "access": "read",
983
- "api": "GET currency-api",
984
- "external": "currency-api",
983
+ "api": "GET /admin/fx/rates",
984
+ "external": null,
985
985
  "side_effects": [],
986
986
  "approval": "auto",
987
987
  "reversible": null,
@@ -990,7 +990,7 @@
990
990
  "legacy": [
991
991
  "convert-currency"
992
992
  ],
993
- "help": "Convert money between currencies using the same free FX source and rounding as the platform's revenue metrics. Pass several source currencies via --from (comma-separated) to get a full rate table in one call, then convert locally — avoids repeated lookups.",
993
+ "help": "GET /admin/fx/rates — convert money between currencies at the platform's own rates and rounding, so totals reconcile with its prices and revenue metrics. Pass several source currencies via --from (comma-separated) to get a full rate table in one call, then convert locally — avoids repeated lookups.",
994
994
  "function": "makea_cli.commands.metrics.cmd.register.<locals>.convert_currency",
995
995
  "flags": [
996
996
  {
@@ -3574,14 +3574,14 @@
3574
3574
  "external": null,
3575
3575
  "side_effects": [
3576
3576
  "moves the order to delivered when the status says so",
3577
- "emails and messages the designer, supplier and admins as the 17track webhook did (none with --silent)"
3577
+ "emails and messages the designer, supplier and admins that the parcel arrived (none with --silent)"
3578
3578
  ],
3579
3579
  "approval": "ask",
3580
3580
  "reversible": null,
3581
3581
  "idempotent": true,
3582
3582
  "limit": null,
3583
3583
  "legacy": [],
3584
- "help": "POST /admin/tracking/carrier_status — apply a carrier status looked up by hand (the tracking-sweep skill) to one order. Runs the 17track webhook path: status, fan-out to every order sharing the number, and the delivered emails.",
3584
+ "help": "POST /admin/tracking/carrier_status — apply a carrier status looked up by hand (the tracking-sweep skill) to one order: status, fan-out to every order sharing the number, and the delivered emails.",
3585
3585
  "function": "makea_cli.commands.orders.cmd.register.<locals>.update_tracking_status",
3586
3586
  "flags": [
3587
3587
  {
@@ -3630,7 +3630,7 @@
3630
3630
  "required": true,
3631
3631
  "multiple": false,
3632
3632
  "default": null,
3633
- "help": "17track status, e.g. Delivered, InTransit."
3633
+ "help": "Carrier status as the public 17track page shows it, e.g. Delivered, InTransit."
3634
3634
  },
3635
3635
  {
3636
3636
  "name": "sub_status",
@@ -0,0 +1,106 @@
1
+ """`admin get currency-conversion` reads the platform's rates from the backend.
2
+
3
+ It used to fetch the free FX source itself and round rates to 2 decimals and
4
+ amounts to the nearest cent, while the platform rounds rates up to 3 decimals
5
+ and amounts up to the cent -- so its totals drifted from the platform's. It
6
+ now asks GET /admin/fx/rates and returns what that says.
7
+
8
+ Run with ``.venv/bin/python -m unittest tests.test_currency_conversion``.
9
+ """
10
+
11
+ import json
12
+ import unittest
13
+ from unittest import mock
14
+
15
+ import httpx
16
+ from click.testing import CliRunner
17
+
18
+ from makea_cli import api_client, identity, output
19
+ from makea_cli.main import build_cli
20
+
21
+ RESULT = {
22
+ "to": "GBP",
23
+ "rates": {"USD": 0.752, "CNY": 0.113},
24
+ "unavailable": [],
25
+ "rate_decimals": 3,
26
+ "rounding": "up",
27
+ "source": "platform (FreeCurrencyService)",
28
+ }
29
+
30
+
31
+ class ApiClientTest(unittest.TestCase):
32
+ def test_rates(self):
33
+ with mock.patch.object(api_client, "get_json", return_value={}) as get:
34
+ api_client.get_fx_rates(to="gbp", sources=["usd", "cny"])
35
+ get.assert_called_once_with("/admin/fx/rates", params={"to": "gbp", "from": "usd,cny"})
36
+
37
+ def test_amount(self):
38
+ with mock.patch.object(api_client, "get_json", return_value={}) as get:
39
+ api_client.get_fx_rates(to="gbp", sources=["cny"], amount=250)
40
+ get.assert_called_once_with(
41
+ "/admin/fx/rates", params={"to": "gbp", "from": "cny", "amount": 250}
42
+ )
43
+
44
+
45
+ class CommandTest(unittest.TestCase):
46
+ def setUp(self):
47
+ self.cli = build_cli()
48
+ self.runner = CliRunner()
49
+ output.state.force_json = True
50
+
51
+ def tearDown(self):
52
+ output.state.force_json = False
53
+
54
+ def _run(self, args, payload):
55
+ with (
56
+ mock.patch.object(identity, "current_persona", return_value="admin"),
57
+ mock.patch.object(api_client, "get_fx_rates", return_value=payload) as get,
58
+ # Nothing is fetched from an FX source directly any more.
59
+ mock.patch.object(httpx, "Client", side_effect=AssertionError("no network")),
60
+ ):
61
+ result = self.runner.invoke(self.cli, ["admin", "get", "currency-conversion", *args])
62
+ return result, get
63
+
64
+ def test_rate_table_is_the_platforms(self):
65
+ result, get = self._run(
66
+ ["--to", "gbp", "--from", "usd,cny"], {"success": True, "result": RESULT}
67
+ )
68
+ self.assertEqual(result.exit_code, 0, result.output)
69
+ get.assert_called_once_with(to="gbp", sources=["usd", "cny"], amount=None)
70
+ self.assertEqual(json.loads(result.output), RESULT)
71
+
72
+ def test_single_amount_is_converted_by_the_platform(self):
73
+ converted = {
74
+ **RESULT,
75
+ "rates": {"CNY": 0.113},
76
+ "from": "CNY",
77
+ "rate": 0.113,
78
+ "amount": 250.0,
79
+ "converted": 28.25,
80
+ }
81
+ result, get = self._run(
82
+ ["--to", "gbp", "--from", "cny", "--amount", "250"],
83
+ {"success": True, "result": converted},
84
+ )
85
+ self.assertEqual(result.exit_code, 0, result.output)
86
+ get.assert_called_once_with(to="gbp", sources=["cny"], amount=250.0)
87
+ self.assertEqual(json.loads(result.output)["converted"], 28.25)
88
+
89
+ def test_amount_with_several_currencies_is_not_sent(self):
90
+ _, get = self._run(
91
+ ["--to", "gbp", "--from", "usd,cny", "--amount", "250"],
92
+ {"success": True, "result": RESULT},
93
+ )
94
+ get.assert_called_once_with(to="gbp", sources=["usd", "cny"], amount=None)
95
+
96
+ def test_a_missing_rate_fails_rather_than_returning_null(self):
97
+ missing = {**RESULT, "rates": {"XYZ": None}, "unavailable": ["XYZ"]}
98
+ result, _ = self._run(
99
+ ["--to", "gbp", "--from", "xyz"], {"success": True, "result": missing}
100
+ )
101
+ self.assertEqual(result.exit_code, 1, result.output)
102
+ self.assertIn("XYZ->GBP", result.output)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()
@@ -22,7 +22,11 @@ class PmReadsTest(unittest.TestCase):
22
22
  "progress_type": "During Production",
23
23
  "metadata": {
24
24
  "milestones": [
25
- {"title": "Cutting", "status": "completed", "time_window": ["3", "5"]},
25
+ {
26
+ "title": "Cutting",
27
+ "status": "completed",
28
+ "time_window": ["3", "5"],
29
+ },
26
30
  ]
27
31
  },
28
32
  }
@@ -50,7 +54,12 @@ class PmReadsTest(unittest.TestCase):
50
54
  raw = {
51
55
  "notes": [
52
56
  {"note_id": "a", "created_at": "2026-09-30T10:00:00", "content": "old"},
53
- {"note_id": "b", "created_at": "2026-10-05T10:00:00", "content": "new", "replies": [{}]},
57
+ {
58
+ "note_id": "b",
59
+ "created_at": "2026-10-05T10:00:00",
60
+ "content": "new",
61
+ "replies": [{}],
62
+ },
54
63
  ]
55
64
  }
56
65
  with mock.patch.object(api_client, "get_json", return_value=raw):
@@ -62,7 +71,9 @@ class PmReadsTest(unittest.TestCase):
62
71
  raw = {"packs": [{"product_id": "p1", "pack_label": "v1", "costing": {"make": 50}}]}
63
72
  with mock.patch.object(api_client, "get_json", return_value=raw):
64
73
  out = api_client.designer_get_costing("st1")
65
- self.assertEqual(out["packs"], [{"product_id": "p1", "pack": "v1", "costing": {"make": 50}}])
74
+ self.assertEqual(
75
+ out["packs"], [{"product_id": "p1", "pack": "v1", "costing": {"make": 50}}]
76
+ )
66
77
 
67
78
 
68
79
  class ExamFixesTest(unittest.TestCase):
@@ -1 +0,0 @@
1
- __version__ = "0.2.13"
File without changes
File without changes
File without changes