@colixsystems/widget-sdk 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
20
  | **CORE** | `useTheme()` | `{ colors, elevation, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`). `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
+ | **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
21
22
  | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
22
23
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
23
24
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
@@ -48,7 +49,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
48
49
  | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
49
50
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
50
51
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
51
- | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. |
52
+ | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
52
53
  | **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
53
54
  | **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
54
55
 
@@ -62,6 +63,14 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
62
63
 
63
64
  `v0.77.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
64
65
 
66
+ ### What's new in 0.85.0 (contract 1.60.0)
67
+
68
+ **A widget must never name a currency — `useWorkspaceCurrency()` resolves it at render time (sc-4686).** A workspace picks the currency it charges its app users in, and the owner usually sets that *after* the app is built (the normal order is prompt first, billing later). So anything a widget wrote down — a `"kr"` in JSX, a `€` in a `manifest.translations` string, a `currency` argument on `requestPayment` — kept displaying the old currency over a charge that had correctly followed the change: one price shown, another taken. Currency is workspace configuration that changes after authoring, exactly like `theme` and `locale`, so it now joins them on the host-resolved `ctx.workspace` slice. `useWorkspaceCurrency()` returns `{ currency, formatMoney }`; `formatMoney(45000)` renders `"450,00 kr"` or `"450,00 €"` from `CONTRACT.currencyFormats` — an explicit table, not `Intl.NumberFormat`, which does not agree between the exported Expo app and react-native-web. Omit `currency` on `requestPayment` and the platform applies the workspace's own, so it can never be wrong. Enforcement tightened to match: `payment-currency` now rejects **any** currency literal (one that matches today still lies tomorrow) and so needs no per-workspace option — it fires in a bare `appstudio-widget lint`, and `lintSource`'s `paymentCurrency` option is removed; a new `no-hardcoded-currency-label` warning catches a symbol or code beside a price in a charging widget. `CONTRACT.version` → `1.60.0`. Additive for a widget that already omits `currency`.
69
+
70
+ ### What's new in 0.84.0 (contract 1.59.0)
71
+
72
+ **A charge is denominated in the WORKSPACE's currency, and a widget that hardcodes a different one no longer publishes (sc-4649).** Every workspace picks the currency it charges its app users in, and `POST /payments/widget-charge` refuses any other code with `UNSUPPORTED_CURRENCY` — but nothing told a widget author which one that was. A widget priced in EUR for a workspace selling in SEK compiled, rendered, and looked finished, then failed every single checkout; the buyer read that as a generic "payment failed" and retried forever. Two changes: `currency` on `requestPayment` is best **omitted** (the platform applies the workspace's own, so it can never be wrong), and a literal that disagrees is now a publish-blocking `payment-currency` finding. Because the expected code is **per-workspace**, the SDK cannot know it: the rule fires only when the caller supplies `lintSource(source, { paymentCurrency })`, which the platform's publish gate does and a local `appstudio-widget lint` does not — it stays silent rather than guessing and flagging correct code. The rule is scoped to the argument of a `requestPayment(...)` call, so a `currency` field elsewhere (a datastore column, an `Intl.NumberFormat` option) is untouched. `CONTRACT.version` → `1.59.0`. Additive; a widget that omits `currency` or already matches its workspace is unaffected.
73
+
65
74
  ### What's new in 0.83.0 (contract 1.58.0)
66
75
 
67
76
  **`PaymentError` tells you WHY a charge was refused, and whether retrying could ever help (sc-4650).** `usePayments()` mapped its rejections by reading `err.response`, but `@colixsystems/payments-client` throws typed errors carrying `.code` / `.status` / `.details` (the parsed error envelope) and no `.response` at all — so every server refusal arrived as `code: "INTERNAL"` and the real reason was buried on `err.cause`. A widget could not tell a workspace that has not declared its business identity yet (`BUSINESS_IDENTITY_REQUIRED`, which no retry clears) from a declined card.
package/dist/contract.cjs CHANGED
@@ -810,6 +810,22 @@ const HOOKS = [
810
810
  requiredContextSlice: ["payments.requestPayment"],
811
811
  scopes: ["payments.charge:appUser"],
812
812
  },
813
+ // sc-4686 — the workspace's charge currency + the only sanctioned way to
814
+ // render money. A widget must NOT name a currency: the owner can change it
815
+ // after the widget was authored, so a baked symbol goes stale over a charge
816
+ // that followed the change.
817
+ {
818
+ name: "useWorkspaceCurrency",
819
+ signature: "useWorkspaceCurrency()",
820
+ returnShape: {
821
+ currency:
822
+ "string // the ISO code this workspace charges its app users in, e.g. \"SEK\"",
823
+ formatMoney:
824
+ "(minorUnits) => string // 45000 -> \"450,00 kr\"; renders identically on web and native. NEVER write a currency symbol or code yourself",
825
+ },
826
+ requiredContextSlice: ["workspace.currency"],
827
+ scopes: null,
828
+ },
813
829
  // sc-890 — send an in-app notification to one app user in the tenant.
814
830
  // IMPERATIVE: send() never fires on mount; the widget calls it from an
815
831
  // event handler. Reads ctx.notifications.send (the injected
@@ -1457,13 +1473,15 @@ const WIDGET_CONTEXT_SHAPE = {
1457
1473
  },
1458
1474
  workspace: {
1459
1475
  description:
1460
- "Workspace identity + resolved theme ({ id, slug, theme, locale }).",
1476
+ "Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
1461
1477
  required: true,
1462
1478
  fields: {
1463
1479
  id: "string",
1464
1480
  slug: "string",
1465
1481
  theme: "ThemeTokens",
1466
1482
  locale: "string",
1483
+ // sc-4686 — the ISO code this workspace charges its app users in.
1484
+ currency: "string",
1467
1485
  },
1468
1486
  },
1469
1487
  navigation: {
@@ -2010,6 +2028,115 @@ const TRANSLATION_API_HOSTS = [
2010
2028
  "lingvanex.com",
2011
2029
  ];
2012
2030
 
2031
+ // sc-4686 — the currency a price renders in when the HOST does not supply the
2032
+ // workspace's own (a host predating the workspace.currency slice, or a caller
2033
+ // that forgot to wire it). A rendering fallback so a price is never blank or
2034
+ // "undefined" — NOT a statement about which currencies the server accepts,
2035
+ // which is per-workspace and operator-configured (sc-4649).
2036
+ const CHARGE_CURRENCY = "SEK";
2037
+
2038
+ // sc-4686 — how each accepted currency is written, so a widget never has to.
2039
+ //
2040
+ // A widget must not name a currency: it is workspace configuration the owner
2041
+ // can change AFTER the widget was authored (see `chargeCurrency` on the
2042
+ // workspace context slice), so a baked "kr" or "€" goes stale the moment they
2043
+ // switch. Widgets render money through `useWorkspaceCurrency().formatMoney`,
2044
+ // which reads this table.
2045
+ //
2046
+ // Deliberately NOT `Intl.NumberFormat`: the exported Expo app and
2047
+ // react-native-web do not agree on its output (the same reason widgets format
2048
+ // dates with `date-fns` rather than `toLocaleDateString`), and this string ends
2049
+ // up on a price the buyer compares against the amount they are charged. An
2050
+ // explicit table renders byte-identically on both hosts.
2051
+ //
2052
+ // `symbol` is written `before` or `after` the amount; `space` inserts a
2053
+ // non-breaking gap between them. `decimals` is the minor-unit exponent, so it
2054
+ // is also how many minor units make one major unit.
2055
+ const CURRENCY_FORMATS = {
2056
+ SEK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2057
+ NOK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2058
+ DKK: { symbol: "kr.", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2059
+ ISK: { symbol: "kr", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: "." },
2060
+ EUR: { symbol: "€", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2061
+ GBP: { symbol: "£", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2062
+ USD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2063
+ CAD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2064
+ AUD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2065
+ NZD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2066
+ CHF: { symbol: "CHF", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "'" },
2067
+ PLN: { symbol: "zł", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2068
+ CZK: { symbol: "Kč", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2069
+ HUF: { symbol: "Ft", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: " " },
2070
+ RON: { symbol: "lei", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2071
+ BGN: { symbol: "лв", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2072
+ JPY: { symbol: "¥", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
2073
+ INR: { symbol: "₹", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2074
+ ILS: { symbol: "₪", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2075
+ ZAR: { symbol: "R", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2076
+ MXN: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2077
+ BRL: { symbol: "R$", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2078
+ SGD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2079
+ HKD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2080
+ AED: { symbol: "AED", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2081
+ TRY: { symbol: "₺", position: "before", space: false, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2082
+ THB: { symbol: "฿", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2083
+ KRW: { symbol: "₩", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
2084
+ CNY: { symbol: "¥", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2085
+ };
2086
+
2087
+ // An unlisted currency still has to render something a buyer can read, so the
2088
+ // ISO code stands in for a symbol rather than the amount appearing bare.
2089
+ const CURRENCY_FORMAT_FALLBACK = Object.freeze({
2090
+ position: "after",
2091
+ space: true,
2092
+ decimals: 2,
2093
+ decimalSeparator: ".",
2094
+ groupSeparator: ",",
2095
+ });
2096
+
2097
+ // sc-4686 — the ISO code a workspace charges in, normalised. An empty value
2098
+ // means a host that predates the `workspace.currency` slice; falling back to the
2099
+ // platform default keeps a price rendering rather than printing "undefined" next
2100
+ // to an amount, which is the one place a blank is unacceptable.
2101
+ function normaliseCurrencyCode(value) {
2102
+ const code = typeof value === "string" ? value.trim().toUpperCase() : "";
2103
+ return code || CHARGE_CURRENCY;
2104
+ }
2105
+
2106
+ /**
2107
+ * `formatMoneyIn("SEK", 45000)` → `"450,00 kr"`.
2108
+ *
2109
+ * Takes MINOR units — the same unit `requestPayment` charges in — so there is no
2110
+ * conversion step for a widget to get wrong. Renders from `CURRENCY_FORMATS`
2111
+ * rather than `Intl.NumberFormat`, which does not produce identical output in
2112
+ * the exported Expo app and react-native-web; this string sits beside the amount
2113
+ * the buyer is actually charged, so the two hosts must agree exactly.
2114
+ *
2115
+ * A non-finite amount renders as zero rather than "NaN": a price whose data has
2116
+ * not loaded should read as an amount, never as a broken string in a checkout.
2117
+ */
2118
+ function formatMoneyIn(currency, minorUnits) {
2119
+ const code = normaliseCurrencyCode(currency);
2120
+ const fmt = CURRENCY_FORMATS[code] || {
2121
+ ...CURRENCY_FORMAT_FALLBACK,
2122
+ symbol: code,
2123
+ };
2124
+ const amount = Number(minorUnits);
2125
+ const safe = Number.isFinite(amount) ? Math.round(amount) : 0;
2126
+ const negative = safe < 0;
2127
+ const digits = String(Math.abs(safe)).padStart(fmt.decimals + 1, "0");
2128
+ const whole = fmt.decimals > 0 ? digits.slice(0, -fmt.decimals) : digits;
2129
+ const cents = fmt.decimals > 0 ? digits.slice(-fmt.decimals) : "";
2130
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, fmt.groupSeparator);
2131
+ const body = cents ? `${grouped}${fmt.decimalSeparator}${cents}` : grouped;
2132
+ const gap = fmt.space ? " " : "";
2133
+ const withSymbol =
2134
+ fmt.position === "before"
2135
+ ? `${fmt.symbol}${gap}${body}`
2136
+ : `${body}${gap}${fmt.symbol}`;
2137
+ return negative ? `-${withSymbol}` : withSymbol;
2138
+ }
2139
+
2013
2140
  function deepFreeze(value) {
2014
2141
  if (value === null || typeof value !== "object") return value;
2015
2142
  if (Object.isFrozen(value)) return value;
@@ -2524,7 +2651,7 @@ const CONTRACT = deepFreeze({
2524
2651
  // `recipient_expr` shape and no recipient column can name them. The member
2525
2652
  // list stays host-side — a script receives a count, never the ids — and
2526
2653
  // `exclude_user_id` keeps an author off their own message.
2527
- version: "1.58.0",
2654
+ version: "1.60.0",
2528
2655
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2529
2656
  hooks: HOOKS,
2530
2657
  primitives: PRIMITIVES,
@@ -2546,6 +2673,9 @@ const CONTRACT = deepFreeze({
2546
2673
  allowedBareImports: ALLOWED_BARE_IMPORTS,
2547
2674
  hostApiUrlPatterns: HOST_API_URL_PATTERNS,
2548
2675
  payloadValueTypes: PAYLOAD_VALUE_TYPES,
2676
+ chargeCurrency: CHARGE_CURRENCY,
2677
+ currencyFormats: CURRENCY_FORMATS,
2678
+ currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
2549
2679
  translationApiHosts: TRANSLATION_API_HOSTS,
2550
2680
  });
2551
2681
 
@@ -2721,6 +2851,8 @@ module.exports = {
2721
2851
  deriveAccentTints,
2722
2852
  gradientAngleToVector,
2723
2853
  normaliseComponentGradient,
2854
+ formatMoneyIn,
2855
+ normaliseCurrencyCode,
2724
2856
  widgetTranslationPrefix,
2725
2857
  widgetTranslationKey,
2726
2858
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -810,6 +810,22 @@ const HOOKS = [
810
810
  requiredContextSlice: ["payments.requestPayment"],
811
811
  scopes: ["payments.charge:appUser"],
812
812
  },
813
+ // sc-4686 — the workspace's charge currency + the only sanctioned way to
814
+ // render money. A widget must NOT name a currency: the owner can change it
815
+ // after the widget was authored, so a baked symbol goes stale over a charge
816
+ // that followed the change.
817
+ {
818
+ name: "useWorkspaceCurrency",
819
+ signature: "useWorkspaceCurrency()",
820
+ returnShape: {
821
+ currency:
822
+ "string // the ISO code this workspace charges its app users in, e.g. \"SEK\"",
823
+ formatMoney:
824
+ "(minorUnits) => string // 45000 -> \"450,00 kr\"; renders identically on web and native. NEVER write a currency symbol or code yourself",
825
+ },
826
+ requiredContextSlice: ["workspace.currency"],
827
+ scopes: null,
828
+ },
813
829
  // sc-890 — send an in-app notification to one app user in the tenant.
814
830
  // IMPERATIVE: send() never fires on mount; the widget calls it from an
815
831
  // event handler. Reads ctx.notifications.send (the injected
@@ -1457,13 +1473,15 @@ const WIDGET_CONTEXT_SHAPE = {
1457
1473
  },
1458
1474
  workspace: {
1459
1475
  description:
1460
- "Workspace identity + resolved theme ({ id, slug, theme, locale }).",
1476
+ "Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
1461
1477
  required: true,
1462
1478
  fields: {
1463
1479
  id: "string",
1464
1480
  slug: "string",
1465
1481
  theme: "ThemeTokens",
1466
1482
  locale: "string",
1483
+ // sc-4686 — the ISO code this workspace charges its app users in.
1484
+ currency: "string",
1467
1485
  },
1468
1486
  },
1469
1487
  navigation: {
@@ -2010,6 +2028,115 @@ const TRANSLATION_API_HOSTS = [
2010
2028
  "lingvanex.com",
2011
2029
  ];
2012
2030
 
2031
+ // sc-4686 — the currency a price renders in when the HOST does not supply the
2032
+ // workspace's own (a host predating the workspace.currency slice, or a caller
2033
+ // that forgot to wire it). A rendering fallback so a price is never blank or
2034
+ // "undefined" — NOT a statement about which currencies the server accepts,
2035
+ // which is per-workspace and operator-configured (sc-4649).
2036
+ const CHARGE_CURRENCY = "SEK";
2037
+
2038
+ // sc-4686 — how each accepted currency is written, so a widget never has to.
2039
+ //
2040
+ // A widget must not name a currency: it is workspace configuration the owner
2041
+ // can change AFTER the widget was authored (see `chargeCurrency` on the
2042
+ // workspace context slice), so a baked "kr" or "€" goes stale the moment they
2043
+ // switch. Widgets render money through `useWorkspaceCurrency().formatMoney`,
2044
+ // which reads this table.
2045
+ //
2046
+ // Deliberately NOT `Intl.NumberFormat`: the exported Expo app and
2047
+ // react-native-web do not agree on its output (the same reason widgets format
2048
+ // dates with `date-fns` rather than `toLocaleDateString`), and this string ends
2049
+ // up on a price the buyer compares against the amount they are charged. An
2050
+ // explicit table renders byte-identically on both hosts.
2051
+ //
2052
+ // `symbol` is written `before` or `after` the amount; `space` inserts a
2053
+ // non-breaking gap between them. `decimals` is the minor-unit exponent, so it
2054
+ // is also how many minor units make one major unit.
2055
+ const CURRENCY_FORMATS = {
2056
+ SEK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2057
+ NOK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2058
+ DKK: { symbol: "kr.", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2059
+ ISK: { symbol: "kr", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: "." },
2060
+ EUR: { symbol: "€", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2061
+ GBP: { symbol: "£", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2062
+ USD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2063
+ CAD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2064
+ AUD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2065
+ NZD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2066
+ CHF: { symbol: "CHF", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "'" },
2067
+ PLN: { symbol: "zł", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2068
+ CZK: { symbol: "Kč", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2069
+ HUF: { symbol: "Ft", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: " " },
2070
+ RON: { symbol: "lei", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2071
+ BGN: { symbol: "лв", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2072
+ JPY: { symbol: "¥", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
2073
+ INR: { symbol: "₹", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2074
+ ILS: { symbol: "₪", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2075
+ ZAR: { symbol: "R", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
2076
+ MXN: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2077
+ BRL: { symbol: "R$", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2078
+ SGD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2079
+ HKD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2080
+ AED: { symbol: "AED", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2081
+ TRY: { symbol: "₺", position: "before", space: false, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
2082
+ THB: { symbol: "฿", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2083
+ KRW: { symbol: "₩", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
2084
+ CNY: { symbol: "¥", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
2085
+ };
2086
+
2087
+ // An unlisted currency still has to render something a buyer can read, so the
2088
+ // ISO code stands in for a symbol rather than the amount appearing bare.
2089
+ const CURRENCY_FORMAT_FALLBACK = Object.freeze({
2090
+ position: "after",
2091
+ space: true,
2092
+ decimals: 2,
2093
+ decimalSeparator: ".",
2094
+ groupSeparator: ",",
2095
+ });
2096
+
2097
+ // sc-4686 — the ISO code a workspace charges in, normalised. An empty value
2098
+ // means a host that predates the `workspace.currency` slice; falling back to the
2099
+ // platform default keeps a price rendering rather than printing "undefined" next
2100
+ // to an amount, which is the one place a blank is unacceptable.
2101
+ function normaliseCurrencyCode(value) {
2102
+ const code = typeof value === "string" ? value.trim().toUpperCase() : "";
2103
+ return code || CHARGE_CURRENCY;
2104
+ }
2105
+
2106
+ /**
2107
+ * `formatMoneyIn("SEK", 45000)` → `"450,00 kr"`.
2108
+ *
2109
+ * Takes MINOR units — the same unit `requestPayment` charges in — so there is no
2110
+ * conversion step for a widget to get wrong. Renders from `CURRENCY_FORMATS`
2111
+ * rather than `Intl.NumberFormat`, which does not produce identical output in
2112
+ * the exported Expo app and react-native-web; this string sits beside the amount
2113
+ * the buyer is actually charged, so the two hosts must agree exactly.
2114
+ *
2115
+ * A non-finite amount renders as zero rather than "NaN": a price whose data has
2116
+ * not loaded should read as an amount, never as a broken string in a checkout.
2117
+ */
2118
+ function formatMoneyIn(currency, minorUnits) {
2119
+ const code = normaliseCurrencyCode(currency);
2120
+ const fmt = CURRENCY_FORMATS[code] || {
2121
+ ...CURRENCY_FORMAT_FALLBACK,
2122
+ symbol: code,
2123
+ };
2124
+ const amount = Number(minorUnits);
2125
+ const safe = Number.isFinite(amount) ? Math.round(amount) : 0;
2126
+ const negative = safe < 0;
2127
+ const digits = String(Math.abs(safe)).padStart(fmt.decimals + 1, "0");
2128
+ const whole = fmt.decimals > 0 ? digits.slice(0, -fmt.decimals) : digits;
2129
+ const cents = fmt.decimals > 0 ? digits.slice(-fmt.decimals) : "";
2130
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, fmt.groupSeparator);
2131
+ const body = cents ? `${grouped}${fmt.decimalSeparator}${cents}` : grouped;
2132
+ const gap = fmt.space ? " " : "";
2133
+ const withSymbol =
2134
+ fmt.position === "before"
2135
+ ? `${fmt.symbol}${gap}${body}`
2136
+ : `${body}${gap}${fmt.symbol}`;
2137
+ return negative ? `-${withSymbol}` : withSymbol;
2138
+ }
2139
+
2013
2140
  function deepFreeze(value) {
2014
2141
  if (value === null || typeof value !== "object") return value;
2015
2142
  if (Object.isFrozen(value)) return value;
@@ -2524,7 +2651,7 @@ const CONTRACT = deepFreeze({
2524
2651
  // `recipient_expr` shape and no recipient column can name them. The member
2525
2652
  // list stays host-side — a script receives a count, never the ids — and
2526
2653
  // `exclude_user_id` keeps an author off their own message.
2527
- version: "1.58.0",
2654
+ version: "1.60.0",
2528
2655
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2529
2656
  hooks: HOOKS,
2530
2657
  primitives: PRIMITIVES,
@@ -2546,6 +2673,9 @@ const CONTRACT = deepFreeze({
2546
2673
  allowedBareImports: ALLOWED_BARE_IMPORTS,
2547
2674
  hostApiUrlPatterns: HOST_API_URL_PATTERNS,
2548
2675
  payloadValueTypes: PAYLOAD_VALUE_TYPES,
2676
+ chargeCurrency: CHARGE_CURRENCY,
2677
+ currencyFormats: CURRENCY_FORMATS,
2678
+ currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
2549
2679
  translationApiHosts: TRANSLATION_API_HOSTS,
2550
2680
  });
2551
2681
 
@@ -2721,6 +2851,8 @@ export {
2721
2851
  deriveAccentTints,
2722
2852
  gradientAngleToVector,
2723
2853
  normaliseComponentGradient,
2854
+ formatMoneyIn,
2855
+ normaliseCurrencyCode,
2724
2856
  widgetTranslationPrefix,
2725
2857
  widgetTranslationKey,
2726
2858
  sharedTranslationPrefix,
package/dist/hooks.js CHANGED
@@ -33,10 +33,15 @@ import React, {
33
33
  // `sharedTranslationKey` builds the tenant-wide predefined key
34
34
  // (`shared.<key>`); `isSharedTranslationKey` tells the hook whether a bare key
35
35
  // is one of the predefined shared keys and so should try the shared namespace.
36
+ // sc-4686 — `formatMoneyIn` lives beside the currency table it reads, for the
37
+ // same single-source reason: the host, the compiler-parity tests and this hook
38
+ // must render a price identically.
36
39
  import {
37
40
  widgetTranslationKey,
38
41
  sharedTranslationKey,
39
42
  isSharedTranslationKey,
43
+ formatMoneyIn,
44
+ normaliseCurrencyCode,
40
45
  } from "./contract.js";
41
46
 
42
47
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -129,6 +134,34 @@ function readInput(inputs, inputName) {
129
134
  : undefined;
130
135
  }
131
136
 
137
+ /**
138
+ * sc-4686 — the workspace's charge currency, and the only sanctioned way for a
139
+ * widget to render money.
140
+ *
141
+ * A widget must never name a currency. The owner picks what the workspace
142
+ * charges its app users in and can change it long AFTER the widget was authored,
143
+ * so a hardcoded "kr" or "€" starts lying about a charge that correctly followed
144
+ * the change. Reading it from the host means every existing widget survives a
145
+ * switch with no regeneration — the same reason `useTheme()` and `useI18n()`
146
+ * resolve the other two pieces of workspace configuration at render time.
147
+ *
148
+ * `formatMoney` takes MINOR units, the same unit `requestPayment` charges in.
149
+ *
150
+ * @returns {{ currency: string, formatMoney: (minorUnits: number) => string }}
151
+ */
152
+ export function useWorkspaceCurrency() {
153
+ const ctx = useWidgetContextOrThrow("useWorkspaceCurrency");
154
+ const currency = ctx.workspace && ctx.workspace.currency;
155
+ // React.useMemo rather than a bare import — see the import block above.
156
+ return React.useMemo(
157
+ () => ({
158
+ currency: normaliseCurrencyCode(currency),
159
+ formatMoney: (minorUnits) => formatMoneyIn(currency, minorUnits),
160
+ }),
161
+ [currency],
162
+ );
163
+ }
164
+
132
165
  /**
133
166
  * Returns the host-provided theme tokens. The host guarantees every field
134
167
  * documented in CONTRACT.themeTokens is present (defaults merged with
package/dist/index.d.ts CHANGED
@@ -846,6 +846,19 @@ export interface SendNotificationApi {
846
846
  */
847
847
  export function useSendNotification(): SendNotificationApi;
848
848
 
849
+ export interface WorkspaceCurrency {
850
+ /** The ISO code this workspace charges its app users in. */
851
+ currency: string;
852
+ /** MINOR units in, a display string out: 45000 -> "450,00 kr". */
853
+ formatMoney: (minorUnits: number) => string;
854
+ }
855
+ /**
856
+ * sc-4686 — the workspace charge currency + the only sanctioned way to render
857
+ * money. Never write a currency symbol or code into a widget: the owner can
858
+ * change it after the widget ships.
859
+ */
860
+ export function useWorkspaceCurrency(): WorkspaceCurrency;
861
+
849
862
  export function useTheme(): ThemeTokens;
850
863
 
851
864
  /**
@@ -1766,7 +1779,13 @@ export interface LintFinding {
1766
1779
  line: number;
1767
1780
  snippet: string;
1768
1781
  }
1769
- export function lintSource(source: string): {
1782
+ export interface LintOptions {
1783
+ manifest?: { requestedScopes?: string[]; supportedPlatforms?: string[] };
1784
+ }
1785
+ export function lintSource(
1786
+ source: string,
1787
+ options?: LintOptions,
1788
+ ): {
1770
1789
  ok: boolean;
1771
1790
  findings: LintFinding[];
1772
1791
  };
@@ -1837,6 +1856,10 @@ export interface AiWidgetContract {
1837
1856
  readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
1838
1857
  readonly bannedApis: ReadonlyArray<ContractBannedApi>;
1839
1858
  readonly allowedBareImports: ReadonlyArray<string>;
1859
+ /** sc-4686 — the render-time fallback currency when the host supplies none. */
1860
+ readonly chargeCurrency: string;
1861
+ /** sc-4686 — how each currency is written; read by `formatMoney`, never Intl. */
1862
+ readonly currencyFormats: Readonly<Record<string, Readonly<{ symbol: string; position: "before" | "after"; space: boolean; decimals: number; decimalSeparator: string; groupSeparator: string }>>>;
1840
1863
  }
1841
1864
 
1842
1865
  export const CONTRACT: AiWidgetContract;
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  useWidgetEvent,
39
39
  useWidgetInput,
40
40
  usePayments,
41
+ useWorkspaceCurrency,
41
42
  useSendNotification,
42
43
  useTheme,
43
44
  useWidgetStyle,
@@ -38,6 +38,7 @@ export {
38
38
  useWidgetEvent,
39
39
  useWidgetInput,
40
40
  usePayments,
41
+ useWorkspaceCurrency,
41
42
  useSendNotification,
42
43
  useTheme,
43
44
  useWidgetStyle,
package/dist/linter.cjs CHANGED
@@ -728,6 +728,95 @@ function _jsxOpenTagEnd(source, from) {
728
728
  return source.length;
729
729
  }
730
730
 
731
+ // sc-4649 / sc-4686 — see linter.js for the rationale comments. The two
732
+ // files must stay in lockstep (a test asserts behaviour-equivalence).
733
+ const REQUEST_PAYMENT_CALL = "requestPayment(";
734
+ // A generous window: the options object is usually inline, occasionally spread
735
+ // over a few lines. Bounded so an unbalanced source can't scan to EOF.
736
+ const REQUEST_PAYMENT_WINDOW = 400;
737
+ const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
738
+
739
+ function _paymentCurrencyRules(source) {
740
+ const findings = [];
741
+ // Comments blanked, strings kept: the currency code IS a string literal.
742
+ const code = _stripNonCode(source, { keepStrings: true });
743
+ const sourceLines = source.split(/\r?\n/);
744
+ let from = 0;
745
+ for (;;) {
746
+ const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
747
+ if (at === -1) break;
748
+ from = at + REQUEST_PAYMENT_CALL.length;
749
+ const match = CURRENCY_LITERAL_RE.exec(
750
+ code.slice(at, at + REQUEST_PAYMENT_WINDOW),
751
+ );
752
+ if (!match) continue;
753
+ // Line of the currency literal itself, not of the call.
754
+ const line = code.slice(0, at + match.index).split(/\r?\n/).length;
755
+ findings.push({
756
+ rule: "payment-currency",
757
+ severity: "error",
758
+ label:
759
+ `requestPayment hardcodes currency "${match[2].toUpperCase()}". Each ` +
760
+ `workspace charges in its OWN currency and can change it after this ` +
761
+ `widget ships, so OMIT currency — the platform applies the ` +
762
+ `workspace's — and show prices with useWorkspaceCurrency().formatMoney.`,
763
+ line,
764
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
765
+ });
766
+ }
767
+ return findings;
768
+ }
769
+
770
+ // sc-4686 — no-hardcoded-currency-label.
771
+ //
772
+ // The charge is safe once `currency` is omitted, but the DISPLAY is not: a "kr"
773
+ // in JSX or a "€" in a translation string keeps showing the old currency after
774
+ // the owner switches, so the widget tells the buyer one currency and charges
775
+ // another. `formatMoney` renders the symbol from the workspace's currency.
776
+ //
777
+ // A `warning`, not an error: this is a text heuristic over string data, and a
778
+ // legitimate non-price use exists (a currency-converter widget's own labels).
779
+ // It drives an AI repair turn without hard-failing a human submission.
780
+ //
781
+ // Only in a widget that CHARGES — a widget with no `requestPayment` is not
782
+ // displaying a price the platform is about to take money for. And only where the
783
+ // symbol sits on a digit or a template hole, so prose can't trip it.
784
+ const CURRENCY_LABEL_RES = [
785
+ // `${` is a template hole, `{` a JSX expression — a price is written both ways.
786
+ /[€£¥₹₺₪]\s*(?:\d|\$?\{)/,
787
+ /(?:\d|\})\s*(?:kr|kr\.|zł|Kč|Ft|lei|лв)\b/,
788
+ /\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b\s*(?:\d|\$\{)/,
789
+ /(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
790
+ ];
791
+
792
+ function _hardcodedCurrencyLabelRules(source) {
793
+ const code = _stripNonCode(source, { keepStrings: true });
794
+ if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
795
+ const findings = [];
796
+ const lines = code.split(/\r?\n/);
797
+ const sourceLines = source.split(/\r?\n/);
798
+ for (let i = 0; i < lines.length; i += 1) {
799
+ // The requestPayment call's own `currency:` literal is the other rule's
800
+ // finding — don't report the same line twice.
801
+ if (CURRENCY_LITERAL_RE.test(lines[i])) continue;
802
+ if (!CURRENCY_LABEL_RES.some((re) => re.test(lines[i]))) continue;
803
+ findings.push({
804
+ rule: "no-hardcoded-currency-label",
805
+ severity: "warning",
806
+ label:
807
+ `this charging widget writes a currency next to a price. The owner can ` +
808
+ `change the workspace's currency after it ships, leaving the label ` +
809
+ `wrong over a correct charge — render money with ` +
810
+ `useWorkspaceCurrency().formatMoney(amountInMinorUnits) instead.`,
811
+ line: i + 1,
812
+ snippet: (sourceLines[i] || "").trim().slice(0, 200),
813
+ });
814
+ // One per line is enough; a price line often matches twice.
815
+ }
816
+ return findings;
817
+ }
818
+
819
+
731
820
  // sc-4650 — soft warning: a widget that charges must tell a failure worth
732
821
  // retrying from a refusal only the workspace owner can lift. Collapsing every
733
822
  // rejection into one "please try again" is what sent payers round an
@@ -851,6 +940,8 @@ function lintSource(source, options) {
851
940
  findings.push(..._reactInScopeRules(source));
852
941
  findings.push(..._imagePercentHeightRules(source));
853
942
  // sc-4650 — soft warning: every payment refusal reported as "try again".
943
+ findings.push(..._paymentCurrencyRules(source));
944
+ findings.push(..._hardcodedCurrencyLabelRules(source));
854
945
  findings.push(..._paymentErrorHandlingRules(source));
855
946
  findings.push(
856
947
  ..._scopeRules(source, options && options.manifest).map((f) => ({
package/dist/linter.js CHANGED
@@ -846,6 +846,109 @@ function _jsxOpenTagEnd(source, from) {
846
846
  return source.length;
847
847
  }
848
848
 
849
+ // sc-4649 / sc-4686 — payment-currency.
850
+ //
851
+ // A widget must not name a currency AT ALL. Each workspace charges its app users
852
+ // in one currency of its own choosing, the owner can change it long after the
853
+ // widget was authored, and the server refuses a charge in anything else — so
854
+ // omitting `currency` is the ONLY always-correct call: the platform applies the
855
+ // workspace's own, and an existing widget follows a switch with no rebuild.
856
+ //
857
+ // That makes this rule workspace-INDEPENDENT (it needs no expected value passed
858
+ // in, unlike its first sc-4649 form) and therefore it also fires in a bare
859
+ // `appstudio-widget lint`, before a third-party developer ever uploads.
860
+ //
861
+ // `error`, not a warning like `payment-error-not-branched`: a literal here either
862
+ // disagrees with the workspace and kills every checkout, or agrees today and
863
+ // silently starts lying the moment the owner switches. Neither is code worth an
864
+ // opt-out directive.
865
+ const REQUEST_PAYMENT_CALL = "requestPayment(";
866
+ // A generous window: the options object is usually inline, occasionally spread
867
+ // over a few lines. Bounded so an unbalanced source can't scan to EOF.
868
+ const REQUEST_PAYMENT_WINDOW = 400;
869
+ const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
870
+
871
+ function _paymentCurrencyRules(source) {
872
+ const findings = [];
873
+ // Comments blanked, strings kept: the currency code IS a string literal.
874
+ const code = _stripNonCode(source, { keepStrings: true });
875
+ const sourceLines = source.split(/\r?\n/);
876
+ let from = 0;
877
+ for (;;) {
878
+ const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
879
+ if (at === -1) break;
880
+ from = at + REQUEST_PAYMENT_CALL.length;
881
+ const match = CURRENCY_LITERAL_RE.exec(
882
+ code.slice(at, at + REQUEST_PAYMENT_WINDOW),
883
+ );
884
+ if (!match) continue;
885
+ // Line of the currency literal itself, not of the call.
886
+ const line = code.slice(0, at + match.index).split(/\r?\n/).length;
887
+ findings.push({
888
+ rule: "payment-currency",
889
+ severity: "error",
890
+ label:
891
+ `requestPayment hardcodes currency "${match[2].toUpperCase()}". Each ` +
892
+ `workspace charges in its OWN currency and can change it after this ` +
893
+ `widget ships, so OMIT currency — the platform applies the ` +
894
+ `workspace's — and show prices with useWorkspaceCurrency().formatMoney.`,
895
+ line,
896
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
897
+ });
898
+ }
899
+ return findings;
900
+ }
901
+
902
+ // sc-4686 — no-hardcoded-currency-label.
903
+ //
904
+ // The charge is safe once `currency` is omitted, but the DISPLAY is not: a "kr"
905
+ // in JSX or a "€" in a translation string keeps showing the old currency after
906
+ // the owner switches, so the widget tells the buyer one currency and charges
907
+ // another. `formatMoney` renders the symbol from the workspace's currency.
908
+ //
909
+ // A `warning`, not an error: this is a text heuristic over string data, and a
910
+ // legitimate non-price use exists (a currency-converter widget's own labels).
911
+ // It drives an AI repair turn without hard-failing a human submission.
912
+ //
913
+ // Only in a widget that CHARGES — a widget with no `requestPayment` is not
914
+ // displaying a price the platform is about to take money for. And only where the
915
+ // symbol sits on a digit or a template hole, so prose can't trip it.
916
+ const CURRENCY_LABEL_RES = [
917
+ // `${` is a template hole, `{` a JSX expression — a price is written both ways.
918
+ /[€£¥₹₺₪]\s*(?:\d|\$?\{)/,
919
+ /(?:\d|\})\s*(?:kr|kr\.|zł|Kč|Ft|lei|лв)\b/,
920
+ /\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b\s*(?:\d|\$\{)/,
921
+ /(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
922
+ ];
923
+
924
+ function _hardcodedCurrencyLabelRules(source) {
925
+ const code = _stripNonCode(source, { keepStrings: true });
926
+ if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
927
+ const findings = [];
928
+ const lines = code.split(/\r?\n/);
929
+ const sourceLines = source.split(/\r?\n/);
930
+ for (let i = 0; i < lines.length; i += 1) {
931
+ // The requestPayment call's own `currency:` literal is the other rule's
932
+ // finding — don't report the same line twice.
933
+ if (CURRENCY_LITERAL_RE.test(lines[i])) continue;
934
+ if (!CURRENCY_LABEL_RES.some((re) => re.test(lines[i]))) continue;
935
+ findings.push({
936
+ rule: "no-hardcoded-currency-label",
937
+ severity: "warning",
938
+ label:
939
+ `this charging widget writes a currency next to a price. The owner can ` +
940
+ `change the workspace's currency after it ships, leaving the label ` +
941
+ `wrong over a correct charge — render money with ` +
942
+ `useWorkspaceCurrency().formatMoney(amountInMinorUnits) instead.`,
943
+ line: i + 1,
944
+ snippet: (sourceLines[i] || "").trim().slice(0, 200),
945
+ });
946
+ // One per line is enough; a price line often matches twice.
947
+ }
948
+ return findings;
949
+ }
950
+
951
+
849
952
  // sc-4650 — soft warning: a widget that charges must tell a failure worth
850
953
  // retrying from a refusal only the workspace owner can lift. Collapsing every
851
954
  // rejection into one "please try again" is what sent payers round an
@@ -981,6 +1084,8 @@ export function lintSource(source, options) {
981
1084
  // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
982
1085
  findings.push(..._imagePercentHeightRules(source));
983
1086
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1087
+ findings.push(..._paymentCurrencyRules(source));
1088
+ findings.push(..._hardcodedCurrencyLabelRules(source));
984
1089
  findings.push(..._paymentErrorHandlingRules(source));
985
1090
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
986
1091
  // line-by-line scan so banned-identifier findings stay first in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.83.0",
3
+ "version": "0.85.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",