@colixsystems/widget-sdk 0.81.0 → 0.83.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
@@ -48,7 +48,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
48
48
  | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
49
49
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
50
50
  | **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` |
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
52
  | **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
53
  | **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
54
 
@@ -62,6 +62,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
62
62
 
63
63
  `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
64
 
65
+ ### What's new in 0.83.0 (contract 1.58.0)
66
+
67
+ **`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.
68
+
69
+ - **Both error shapes are read, and the envelope wins.** The mapper takes the server's own `code` (`BUSINESS_IDENTITY_REQUIRED`, `UNSUPPORTED_CURRENCY`, `PAYMENTS_SCOPE_NOT_GRANTED`, …) over the client's status-derived class code, and keeps the server's user-safe `message`. The host's local `.response` rejections (no install bound → `PAYMENTS_UNAVAILABLE`) still map exactly as before.
70
+ - **New `retryable` flag.** `false` for any refusal only the workspace owner, the manifest, or the amount can lift; `true` for a decline, a provider blip, or an unknown failure. Branch on it: render `err.message` and drop the retry control when it is `false`, and keep "try again" for the retryable case only.
71
+ - **New lint warning `payment-error-not-branched`.** A widget that calls `requestPayment()` but never reads `retryable` (or branches on an explicit `err.code`) is flagged — non-blocking, so it never fails a publish.
72
+
73
+ ### What's new in 0.82.0 (contract 1.57.0)
74
+
75
+ **Server-action scripts can notify a record's permission subjects (REQ-ACTION-NOTIFY-SUBJECTS, sc-4586).** `await notifications.notifyRecordSubjects(tableId, recordId, { title, body, link, emit_email, emit_push, exclude_user_id })` resolves to `{ recipients }` and notifies every app user whose per-record grant lets them **read** that record. This addresses the one audience the other two primitives cannot name: when membership *is* the ACL, there is no recipient column to read. The Chat widget is the case in point — a channel's participants ARE that channel record's grants, so neither a `recipient_expr` (which resolves only a fixed id or a single user/group reference column) nor a `notifyUser` loop over a column that does not exist can reach them. Subject kinds follow REQ-ACL-09: a `user` grant notifies its user, a `group` grant expands through its memberships, and the two synthetic kinds (`authenticated`, `everyone`) are skipped because they address the whole workspace rather than a membership. Recipients are **deduplicated**, so someone reachable through both a direct grant and a granted group is notified once, and `exclude_user_id` drops one — pass the author so nobody is notified of their own write. Dispatch goes through the same `notifyUser` path as before, so the always-written inbox row, the preference-gated email + push mirrors, the link sanitiser and the title/body caps are inherited unchanged, and written rows count toward the same per-run cap of 500. The tenant is bound host-side and the table/record pair is verified against it, so a foreign or missing id resolves to `{ recipients: 0 }` rather than distinguishing "absent" from "not yours". **The script never receives the member list — only the count.** `CONTRACT.version` → `1.57.0`. Additive; no widget hook, primitive, manifest field, or token changed shape.
76
+
77
+ ### 0.82.0 also carries (contract 1.56.0)
78
+
79
+ **Server-action scripts gain a `notifications` global (REQ-ACTION-NOTIFY, sc-4514).** A `scriptSource` action can now send a real notification to a recipient it resolves at run time: `await notifications.notifyUser(userId, { title, body, link, emit_email, emit_push })` and `await notifications.notifyGroup(groupId, opts)`. Options are **snake_case**, matching every other shape a script sees. `notifyUser` resolves to the created notification row (or `null` when the recipient was skipped); `notifyGroup` resolves to `{ recipients }`. This is a directness change rather than a new capability: an action could already notify indirectly by writing into a table carrying an enabled `NotificationRule`, but that costs a throwaway table, a per-table rule, a recipient expressible only as a fixed id or one reference column — and it fails silently, since with no rule attached the row just lands and the run still reports success. `POST /notifications/send` is no alternative (it needs an app-user JWT an action cannot hold). The direct call removes the intermediary and makes a non-delivery throw. Both methods delegate to the platform's one notification dispatch path, so the inbox row is always written, the email + push mirrors respect the recipient's channel preferences, and the push ping never carries the title or body. The tenant is bound host-side: a recipient in another workspace, a soft-deleted group, or a deactivated user is a silent skip, never a cross-tenant write. A blank `title`, a non-string `body`, or exceeding the per-run cap of 500 written notifications throws a catchable Error. New entry in `CONTRACT.actionScriptGlobals`; `CONTRACT.version` → `1.56.0`. Additive — no widget hook, primitive, manifest field, or token changed shape.
80
+
65
81
  ### What's new in 0.78.0
66
82
 
67
83
  **New `useIdentification()` hook — identify a visitor who is NOT signed in (REQ-IDENT, sc-4313).** A new IDENTIFICATION hook reading a newly-injected `ctx.identification` slice (the new `@colixsystems/identification-client`, constructed by both the web Player and the native Expo export). Returns `{ available, availabilityLoading, status, qr, autoStartToken, message, identity, identificationId, loading, error, start, refresh, cancel, reset }`.
@@ -620,7 +636,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
620
636
 
621
637
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
622
638
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
623
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
639
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
624
640
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
625
641
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
626
642
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -803,7 +803,7 @@ const HOOKS = [
803
803
  signature: "usePayments()",
804
804
  returnShape: {
805
805
  requestPayment:
806
- "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError",
806
+ "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
807
807
  getPayment:
808
808
  "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
809
809
  },
@@ -1269,6 +1269,7 @@ const ACTION_SCRIPT_GLOBALS = [
1269
1269
  "secrets",
1270
1270
  "fetch",
1271
1271
  "connectors",
1272
+ "notifications",
1272
1273
  "console",
1273
1274
  "record",
1274
1275
  "tenantId",
@@ -2506,7 +2507,24 @@ const CONTRACT = deepFreeze({
2506
2507
  // already measured its own box with onLayout; this promotes that one
2507
2508
  // pattern to the SDK so custom and marketplace widgets get it too,
2508
2509
  // instead of each rolling its own.
2509
- version: "1.55.0",
2510
+ // 1.55.0: additive (sc-4505) — `useWidgetInput(inputName)` + the
2511
+ // `PAYLOAD_VALUE_TYPES` a widget event payload field may declare, so
2512
+ // widgets on one page relate through declared inputs.
2513
+ // 1.56.0: additive (sc-4514) — `notifications` joins
2514
+ // `actionScriptGlobals`. `notifications.notifyUser(id, opts)` /
2515
+ // `notifyGroup(id, opts)` delegate to the one notification dispatch path.
2516
+ // Directness, not a new capability: an action could already notify by
2517
+ // writing into a table carrying a NotificationRule, but that needs a
2518
+ // throwaway table plus a per-table rule and fails SILENTLY when no rule
2519
+ // is attached — the row lands and the run still reports success.
2520
+ // 1.57.0: additive (sc-4586) — `notifications.notifyRecordSubjects(tableId,
2521
+ // recordId, opts)`, resolving to { recipients }. Notifies whoever a
2522
+ // record's per-record grants let read it, which is the only way to address
2523
+ // membership-by-ACL: a Chat channel's participants ARE its grants, so no
2524
+ // `recipient_expr` shape and no recipient column can name them. The member
2525
+ // list stays host-side — a script receives a count, never the ids — and
2526
+ // `exclude_user_id` keeps an author off their own message.
2527
+ version: "1.58.0",
2510
2528
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2511
2529
  hooks: HOOKS,
2512
2530
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -803,7 +803,7 @@ const HOOKS = [
803
803
  signature: "usePayments()",
804
804
  returnShape: {
805
805
  requestPayment:
806
- "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError",
806
+ "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
807
807
  getPayment:
808
808
  "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
809
809
  },
@@ -1269,6 +1269,7 @@ const ACTION_SCRIPT_GLOBALS = [
1269
1269
  "secrets",
1270
1270
  "fetch",
1271
1271
  "connectors",
1272
+ "notifications",
1272
1273
  "console",
1273
1274
  "record",
1274
1275
  "tenantId",
@@ -2506,7 +2507,24 @@ const CONTRACT = deepFreeze({
2506
2507
  // already measured its own box with onLayout; this promotes that one
2507
2508
  // pattern to the SDK so custom and marketplace widgets get it too,
2508
2509
  // instead of each rolling its own.
2509
- version: "1.55.0",
2510
+ // 1.55.0: additive (sc-4505) — `useWidgetInput(inputName)` + the
2511
+ // `PAYLOAD_VALUE_TYPES` a widget event payload field may declare, so
2512
+ // widgets on one page relate through declared inputs.
2513
+ // 1.56.0: additive (sc-4514) — `notifications` joins
2514
+ // `actionScriptGlobals`. `notifications.notifyUser(id, opts)` /
2515
+ // `notifyGroup(id, opts)` delegate to the one notification dispatch path.
2516
+ // Directness, not a new capability: an action could already notify by
2517
+ // writing into a table carrying a NotificationRule, but that needs a
2518
+ // throwaway table plus a per-table rule and fails SILENTLY when no rule
2519
+ // is attached — the row lands and the run still reports success.
2520
+ // 1.57.0: additive (sc-4586) — `notifications.notifyRecordSubjects(tableId,
2521
+ // recordId, opts)`, resolving to { recipients }. Notifies whoever a
2522
+ // record's per-record grants let read it, which is the only way to address
2523
+ // membership-by-ACL: a Chat channel's participants ARE its grants, so no
2524
+ // `recipient_expr` shape and no recipient column can name them. The member
2525
+ // list stays host-side — a script receives a count, never the ids — and
2526
+ // `exclude_user_id` keeps an author off their own message.
2527
+ version: "1.58.0",
2510
2528
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2511
2529
  hooks: HOOKS,
2512
2530
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -3462,39 +3462,83 @@ export function useIdentification(options) {
3462
3462
  * requestPayment, getPayment. Covers: usePayments.
3463
3463
  * ==========================================================================*/
3464
3464
 
3465
+ // sc-4650 — the refusals that retrying the SAME charge can never clear: the
3466
+ // workspace, the widget manifest, or the amount has to change first. Everything
3467
+ // else (a decline, a provider blip, an unknown failure) is worth another
3468
+ // attempt. A widget reads this through `PaymentError.retryable`.
3469
+ const NON_RETRYABLE_PAYMENT_CODES = new Set([
3470
+ "AUTH_REQUIRED",
3471
+ "AMOUNT_TOO_LARGE",
3472
+ "BUSINESS_IDENTITY_REQUIRED",
3473
+ "CONNECT_NOT_READY",
3474
+ "FORBIDDEN",
3475
+ "INVALID_AMOUNT",
3476
+ "NOT_FOUND",
3477
+ "PAYMENTS_DISABLED",
3478
+ "PAYMENTS_SCOPE_NOT_GRANTED",
3479
+ "PAYMENTS_UNAVAILABLE",
3480
+ "UNSUPPORTED_CURRENCY",
3481
+ "VALIDATION",
3482
+ ]);
3483
+
3465
3484
  /**
3466
3485
  * Structured error thrown by `usePayments` callbacks. Carries a stable
3467
- * `code` so widgets can branch without parsing message strings.
3468
- *
3469
- * `code` is one of:
3470
- * - "AUTH_REQUIRED" — no signed-in app user
3471
- * - "PAYMENTS_SCOPE_NOT_GRANTED"— widget lacks payments.charge:appUser
3486
+ * `code` so widgets can branch without parsing message strings, and
3487
+ * `retryable` so they can tell "try again" from "this cannot work yet".
3488
+ *
3489
+ * `code` is the server's own code when it sent one, else a status-derived
3490
+ * fallback:
3491
+ * - "AUTH_REQUIRED" — no signed-in app user
3492
+ * - "BUSINESS_IDENTITY_REQUIRED" — the workspace has not declared its
3493
+ * business identity, so it may not collect
3494
+ * money yet
3495
+ * - "PAYMENTS_SCOPE_NOT_GRANTED" — widget lacks payments.charge:appUser
3496
+ * - "PAYMENTS_UNAVAILABLE" — no installed widget / no payments host
3497
+ * - "UNSUPPORTED_CURRENCY" — not the platform's charge currency
3472
3498
  * - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" — bad request
3473
3499
  * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
3474
- * - "DECLINED" — the charge was declined
3475
- * - "INTERNAL" — anything else
3500
+ * - "DECLINED" — the charge was declined
3501
+ * - "INTERNAL" — anything else
3502
+ *
3503
+ * `message` is the server's own user-safe sentence when it sent one — show it
3504
+ * for a non-retryable refusal rather than a generic "payment failed", which
3505
+ * tells the payer nothing about a block only the workspace owner can lift.
3476
3506
  */
3477
3507
  export class PaymentError extends Error {
3478
3508
  constructor(code, message, opts) {
3479
3509
  super(message);
3480
3510
  this.name = "PaymentError";
3481
3511
  this.code = code;
3512
+ this.retryable = !NON_RETRYABLE_PAYMENT_CODES.has(code);
3482
3513
  if (opts && opts.cause) this.cause = opts.cause;
3483
3514
  }
3484
3515
  }
3485
3516
 
3517
+ // sc-4650 — the payments client throws typed `PaymentsError` subclasses that
3518
+ // carry `.code` / `.status` / `.details` (the parsed error envelope) and NO
3519
+ // `.response`, while the host's local rejections carry `.response` instead.
3520
+ // Reading only `.response` collapsed every server refusal to "INTERNAL" and
3521
+ // buried the real code, so a widget could not tell a business-identity block
3522
+ // from a declined card. Read BOTH shapes, and prefer the envelope's `code`
3523
+ // (the precise reason) over the client's status-derived class code.
3486
3524
  function toPaymentError(err) {
3487
3525
  if (err instanceof PaymentError) return err;
3526
+ const response = (err && err.response) || null;
3527
+ const body = (response && response.data) || (err && err.details) || null;
3488
3528
  const status =
3489
- err && err.response && typeof err.response.status === "number"
3490
- ? err.response.status
3491
- : null;
3492
- const bodyCode =
3493
- err && err.response && err.response.data && err.response.data.code;
3494
- const bodyMessage =
3495
- err && err.response && err.response.data && err.response.data.error;
3529
+ response && typeof response.status === "number"
3530
+ ? response.status
3531
+ : err && typeof err.status === "number" && err.status
3532
+ ? err.status
3533
+ : null;
3534
+ const bodyCode = body && body.code;
3535
+ const clientCode = err && err.code;
3536
+ // `message` is the canonical envelope's field (REQ-GEN-05); `error` is the
3537
+ // ad-hoc envelope a few older surfaces still emit.
3538
+ const bodyMessage = body && (body.message || body.error);
3496
3539
  let code = "INTERNAL";
3497
3540
  if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
3541
+ else if (typeof clientCode === "string" && clientCode) code = clientCode;
3498
3542
  else if (status === 401) code = "AUTH_REQUIRED";
3499
3543
  else if (status === 402) code = "DECLINED";
3500
3544
  else if (status === 403) code = "FORBIDDEN";
package/dist/index.d.ts CHANGED
@@ -766,6 +766,40 @@ export interface PaymentsApi {
766
766
  */
767
767
  export function usePayments(): PaymentsApi;
768
768
 
769
+ /**
770
+ * Rejection thrown by both `usePayments()` callbacks. `code` is the server's
771
+ * own reason when it sent one; `message` is its user-safe sentence. Branch on
772
+ * `retryable`: false means the same charge can never succeed until the
773
+ * workspace, the manifest, or the amount changes — show the reason, not a
774
+ * "try again" that loops the payer forever.
775
+ */
776
+ export class PaymentError extends Error {
777
+ code:
778
+ | "AUTH_REQUIRED"
779
+ | "BUSINESS_IDENTITY_REQUIRED"
780
+ | "PAYMENTS_SCOPE_NOT_GRANTED"
781
+ | "PAYMENTS_UNAVAILABLE"
782
+ | "UNSUPPORTED_CURRENCY"
783
+ | "INVALID_AMOUNT"
784
+ | "AMOUNT_TOO_LARGE"
785
+ | "VALIDATION"
786
+ | "CONNECT_NOT_READY"
787
+ | "PAYMENTS_DISABLED"
788
+ | "DECLINED"
789
+ | "FORBIDDEN"
790
+ | "NOT_FOUND"
791
+ | "RATE_LIMITED"
792
+ | "INTERNAL"
793
+ | string;
794
+ /** False when retrying the same charge can never succeed. */
795
+ retryable: boolean;
796
+ constructor(
797
+ code: string,
798
+ message: string,
799
+ opts?: { cause?: unknown },
800
+ );
801
+ }
802
+
769
803
  /**
770
804
  * Arguments for `useSendNotification().send(...)`. snake_case VERBATIM — this
771
805
  * is the wire contract (REQ-GEN-09). `recipient_user_id`, `title`, and `body`
package/dist/linter.cjs CHANGED
@@ -728,6 +728,34 @@ function _jsxOpenTagEnd(source, from) {
728
728
  return source.length;
729
729
  }
730
730
 
731
+ // sc-4650 — soft warning: a widget that charges must tell a failure worth
732
+ // retrying from a refusal only the workspace owner can lift. Collapsing every
733
+ // rejection into one "please try again" is what sent payers round an
734
+ // unbreakable loop on BUSINESS_IDENTITY_REQUIRED. `PaymentError.retryable` (or
735
+ // an explicit `err.code === "…"` branch) is the signal; strings and comments
736
+ // are blanked so prose about retrying never satisfies the check.
737
+ function _paymentErrorHandlingRules(source) {
738
+ const code = _stripNonCode(source);
739
+ const call = /\brequestPayment\s*\(/.exec(code);
740
+ if (!call) return [];
741
+ if (/\bretryable\b/.test(code)) return [];
742
+ if (/\bcode\s*===/.test(code)) return [];
743
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
744
+ return [
745
+ {
746
+ rule: "payment-error-not-branched",
747
+ severity: "warning",
748
+ label:
749
+ `charges with requestPayment() but never reads ` +
750
+ `PaymentError.retryable — a refusal like BUSINESS_IDENTITY_REQUIRED ` +
751
+ `can never succeed on retry, so render err.message instead of a ` +
752
+ `generic "try again".`,
753
+ line,
754
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
755
+ },
756
+ ];
757
+ }
758
+
731
759
  function _imagePercentHeightRules(source) {
732
760
  const findings = [];
733
761
  const code = _stripNonCode(source, { keepStrings: true });
@@ -822,6 +850,8 @@ function lintSource(source, options) {
822
850
  findings.push(..._lucideIconRules(source));
823
851
  findings.push(..._reactInScopeRules(source));
824
852
  findings.push(..._imagePercentHeightRules(source));
853
+ // sc-4650 — soft warning: every payment refusal reported as "try again".
854
+ findings.push(..._paymentErrorHandlingRules(source));
825
855
  findings.push(
826
856
  ..._scopeRules(source, options && options.manifest).map((f) => ({
827
857
  ...f,
package/dist/linter.js CHANGED
@@ -846,6 +846,34 @@ function _jsxOpenTagEnd(source, from) {
846
846
  return source.length;
847
847
  }
848
848
 
849
+ // sc-4650 — soft warning: a widget that charges must tell a failure worth
850
+ // retrying from a refusal only the workspace owner can lift. Collapsing every
851
+ // rejection into one "please try again" is what sent payers round an
852
+ // unbreakable loop on BUSINESS_IDENTITY_REQUIRED. `PaymentError.retryable` (or
853
+ // an explicit `err.code === "…"` branch) is the signal; strings and comments
854
+ // are blanked so prose about retrying never satisfies the check.
855
+ function _paymentErrorHandlingRules(source) {
856
+ const code = _stripNonCode(source);
857
+ const call = /\brequestPayment\s*\(/.exec(code);
858
+ if (!call) return [];
859
+ if (/\bretryable\b/.test(code)) return [];
860
+ if (/\bcode\s*===/.test(code)) return [];
861
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
862
+ return [
863
+ {
864
+ rule: "payment-error-not-branched",
865
+ severity: "warning",
866
+ label:
867
+ `charges with requestPayment() but never reads ` +
868
+ `PaymentError.retryable — a refusal like BUSINESS_IDENTITY_REQUIRED ` +
869
+ `can never succeed on retry, so render err.message instead of a ` +
870
+ `generic "try again".`,
871
+ line,
872
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
873
+ },
874
+ ];
875
+ }
876
+
849
877
  function _imagePercentHeightRules(source) {
850
878
  const findings = [];
851
879
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -952,6 +980,8 @@ export function lintSource(source, options) {
952
980
  findings.push(..._reactInScopeRules(source));
953
981
  // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
954
982
  findings.push(..._imagePercentHeightRules(source));
983
+ // sc-4650 — soft warning: every payment refusal reported as "try again".
984
+ findings.push(..._paymentErrorHandlingRules(source));
955
985
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
956
986
  // line-by-line scan so banned-identifier findings stay first in the
957
987
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.81.0",
3
+ "version": "0.83.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",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"