@colixsystems/widget-sdk 0.285.0 → 0.287.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
@@ -10,7 +10,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
10
10
  | `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
11
11
  | `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
12
12
  | `ctx.widget.assets` | — (built by the host) | the files the widget package bundles: a map of file name → absolute URL, `{}` when it bundles nothing — what `useBundledAsset()` resolves |
13
- | `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
13
+ | `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)`, the access plans and the app subscription |
14
14
 
15
15
  **Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
16
16
 
@@ -75,6 +75,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
75
75
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
76
76
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
77
77
  | **PAYMENTS** (`ctx.payments`) | `useAccessPlans()` | `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }` | `ctx.payments.{listAccessPlans,purchaseAccessPlan,cancelAccessPlan}` — `payments.charge:appUser`. Plans selling access to restricted pages; `purchase(planId)` names only the plan, the server sets price and group. `purchaseAvailable` is `false` in an iOS / Android build (digital goods need store billing): show a visible not-available state. |
78
+ | **PAYMENTS** (`ctx.payments`) | `useAppSubscription()` | `{ subscription, loading, error, cancel, refresh }` | `ctx.payments.{getAppSubscription,cancelAppSubscription}` — `payments.charge:appUser`. The signed-in user's subscription to a paid app (`{ active, renews, access_ends_at }`); `cancel()` stops it renewing, and access runs to `access_ends_at`. |
78
79
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. `requestPayment` requires `kind` (`"physical"` \| `"service"` \| `"digital"`): what is sold. A `"digital"` charge is refused in an iOS or Android build (`DIGITAL_SALE_NEEDS_STORE_BILLING`). 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). |
79
80
  | **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`. |
80
81
  | **NOTIFICATIONS** (`ctx.notifications`) | `useRecordMutes(tableId)` | `{ isMuted, loading, error, refetch }` | `ctx.notifications.mutes` — no scope. Which records of one table the signed-in user has muted, in one request for a whole list; `isMuted(recordId)` turns false when a timed mute lapses, and a `useRecordMute` write shows at once. |
@@ -89,7 +90,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
89
90
 
90
91
  ## Status
91
92
 
92
- `v0.285.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**.
93
+ `v0.287.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**.
94
+
95
+ ### What's new in 0.287.0 (contract 1.235.0)
96
+
97
+ **`useAppSubscription()` — a paid app's subscriber cancels their own subscription.** When a workspace charges its app users a monthly price for the whole app, `useAppSubscription()` returns `{ subscription, loading, error, cancel, refresh }`, where `subscription` is `{ active, renews, access_ends_at }`: `active` says the signed-in user holds paid access now, `renews` that a subscription will charge again, and `access_ends_at` when access ends without a renewal (null while it has no end). Offer a "Cancel subscription" control whenever `renews` is true: `cancel()` stops the renewal, access continues until `access_ends_at`, and nothing is refunded. Access bought in the App Store or Google Play is cancelled in the store and reads `renews: false`. Needs the `payments.charge:appUser` scope. The injected payments client gains `getAppSubscription()` and `cancelAppSubscription()` (`@colixsystems/payments-client` 0.5.0).
98
+
99
+ ### What's new in 0.286.0 (contract 1.234.0)
100
+
101
+ **Action scripts only: `previous` and `changedFields`.** `CONTRACT.actionScriptGlobals` lists the two new runner globals a `record_updated` Action script reads: the row before the edit and the columns whose value changed. Nothing changes for widget authors.
93
102
 
94
103
  ### What's new in 0.285.0 (contract 1.233.0)
95
104
 
package/dist/contract.cjs CHANGED
@@ -2407,6 +2407,22 @@ const HOOKS = [
2407
2407
  ],
2408
2408
  scopes: ["payments.charge:appUser"],
2409
2409
  },
2410
+ // sc-15342 — a paid app's subscriber cancels their own subscription.
2411
+ {
2412
+ name: "useAppSubscription",
2413
+ signature: "useAppSubscription()",
2414
+ returnShape: {
2415
+ subscription:
2416
+ "{ active, renews, access_ends_at } | null // the signed-in user's subscription to the app itself (a paid app): active = they hold paid access now; renews = a subscription will charge again (false once cancelled, or when bought in the App Store / Google Play); access_ends_at = when access ends without a renewal (null while it has no end, or when not held)",
2417
+ loading: "boolean",
2418
+ error: "PaymentError | null",
2419
+ cancel:
2420
+ "() => Promise<{ active, renews: false, access_ends_at }> // stop it renewing; access runs to access_ends_at, nothing is refunded — offer it whenever renews is true",
2421
+ refresh: "() => Promise<void>",
2422
+ },
2423
+ requiredContextSlice: ["payments.getAppSubscription", "payments.cancelAppSubscription"],
2424
+ scopes: ["payments.charge:appUser"],
2425
+ },
2410
2426
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2411
2427
  // render money. A widget must NOT name a currency: the owner can change it
2412
2428
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3945,6 +3961,9 @@ const ACTION_SCRIPT_GLOBALS = [
3945
3961
  "directory",
3946
3962
  "console",
3947
3963
  "record",
3964
+ // sc-15609 — on record_updated, the row before the edit and what changed.
3965
+ "previous",
3966
+ "changedFields",
3948
3967
  // sc-5366 — `{ body }` on a run fired by the inbound `http_post` trigger,
3949
3968
  // null on every other trigger.
3950
3969
  "request",
@@ -4452,7 +4471,7 @@ const WIDGET_CONTEXT_SHAPE = {
4452
4471
  },
4453
4472
  payments: {
4454
4473
  description:
4455
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4474
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4456
4475
  required: true,
4457
4476
  fields: {
4458
4477
  requestPayment: "function",
@@ -4460,6 +4479,8 @@ const WIDGET_CONTEXT_SHAPE = {
4460
4479
  listAccessPlans: "function",
4461
4480
  purchaseAccessPlan: "function",
4462
4481
  cancelAccessPlan: "function",
4482
+ getAppSubscription: "function",
4483
+ cancelAppSubscription: "function",
4463
4484
  },
4464
4485
  },
4465
4486
  // sc-890 — backs useSendNotification().
@@ -6719,7 +6740,12 @@ const CONTRACT = deepFreeze({
6719
6740
  // "Rows per page" property.
6720
6741
  // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6721
6742
  // `deleteWhenLastMemberLeaves: true`.
6722
- version: "1.233.0",
6743
+ // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6744
+ // `changedFields`; no widget hook, primitive or manifest field changed.
6745
+ // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6746
+ // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6747
+ // app's subscriber cancels their own subscription.
6748
+ version: "1.235.0",
6723
6749
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6724
6750
  hooks: HOOKS,
6725
6751
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2403,6 +2403,22 @@ const HOOKS = [
2403
2403
  ],
2404
2404
  scopes: ["payments.charge:appUser"],
2405
2405
  },
2406
+ // sc-15342 — a paid app's subscriber cancels their own subscription.
2407
+ {
2408
+ name: "useAppSubscription",
2409
+ signature: "useAppSubscription()",
2410
+ returnShape: {
2411
+ subscription:
2412
+ "{ active, renews, access_ends_at } | null // the signed-in user's subscription to the app itself (a paid app): active = they hold paid access now; renews = a subscription will charge again (false once cancelled, or when bought in the App Store / Google Play); access_ends_at = when access ends without a renewal (null while it has no end, or when not held)",
2413
+ loading: "boolean",
2414
+ error: "PaymentError | null",
2415
+ cancel:
2416
+ "() => Promise<{ active, renews: false, access_ends_at }> // stop it renewing; access runs to access_ends_at, nothing is refunded — offer it whenever renews is true",
2417
+ refresh: "() => Promise<void>",
2418
+ },
2419
+ requiredContextSlice: ["payments.getAppSubscription", "payments.cancelAppSubscription"],
2420
+ scopes: ["payments.charge:appUser"],
2421
+ },
2406
2422
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2407
2423
  // render money. A widget must NOT name a currency: the owner can change it
2408
2424
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3941,6 +3957,9 @@ const ACTION_SCRIPT_GLOBALS = [
3941
3957
  "directory",
3942
3958
  "console",
3943
3959
  "record",
3960
+ // sc-15609 — on record_updated, the row before the edit and what changed.
3961
+ "previous",
3962
+ "changedFields",
3944
3963
  // sc-5366 — `{ body }` on a run fired by the inbound `http_post` trigger,
3945
3964
  // null on every other trigger.
3946
3965
  "request",
@@ -4448,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4448
4467
  },
4449
4468
  payments: {
4450
4469
  description:
4451
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4470
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4452
4471
  required: true,
4453
4472
  fields: {
4454
4473
  requestPayment: "function",
@@ -4456,6 +4475,8 @@ const WIDGET_CONTEXT_SHAPE = {
4456
4475
  listAccessPlans: "function",
4457
4476
  purchaseAccessPlan: "function",
4458
4477
  cancelAccessPlan: "function",
4478
+ getAppSubscription: "function",
4479
+ cancelAppSubscription: "function",
4459
4480
  },
4460
4481
  },
4461
4482
  // sc-890 — backs useSendNotification().
@@ -6715,7 +6736,12 @@ const CONTRACT = deepFreeze({
6715
6736
  // "Rows per page" property.
6716
6737
  // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6717
6738
  // `deleteWhenLastMemberLeaves: true`.
6718
- version: "1.233.0",
6739
+ // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6740
+ // `changedFields`; no widget hook, primitive or manifest field changed.
6741
+ // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6742
+ // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6743
+ // app's subscriber cancels their own subscription.
6744
+ version: "1.235.0",
6719
6745
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6720
6746
  hooks: HOOKS,
6721
6747
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -7104,91 +7104,131 @@ export function usePayments() {
7104
7104
  * when `retryable` is false. Requires the `payments.charge:appUser` scope.
7105
7105
  */
7106
7106
  export function useAccessPlans() {
7107
- const ctx = useWidgetContextOrThrow("useAccessPlans");
7107
+ const { state, refresh, call } = usePaymentsResource(
7108
+ "useAccessPlans",
7109
+ "listAccessPlans",
7110
+ "Plans are only available for an installed widget running in the app.",
7111
+ _accessPlansState,
7112
+ );
7113
+
7114
+ const purchase = useCallback(
7115
+ async (planId, opts) => {
7116
+ const res = await call("purchaseAccessPlan", {
7117
+ plan_id: planId,
7118
+ return_path: opts && opts.return_path,
7119
+ });
7120
+ if (res && res.status === "ACTIVE") await refresh();
7121
+ return res;
7122
+ },
7123
+ [call, refresh],
7124
+ );
7125
+
7126
+ const cancel = useCallback(
7127
+ async (planId) => {
7128
+ const res = await call("cancelAccessPlan", planId);
7129
+ await refresh();
7130
+ return res;
7131
+ },
7132
+ [call, refresh],
7133
+ );
7134
+
7135
+ return { ...state, purchase, cancel, refresh };
7136
+ }
7137
+
7138
+ function _accessPlansState(res) {
7139
+ return {
7140
+ plans: Array.isArray(res && res.data) ? res.data : [],
7141
+ purchaseAvailable: Boolean(res && res.meta && res.meta.purchase_available),
7142
+ };
7143
+ }
7144
+
7145
+ /**
7146
+ * sc-15342 — the signed-in app user's own subscription to the app itself (a
7147
+ * paid app), and the control to stop it renewing.
7148
+ *
7149
+ * subscription → { active, renews, access_ends_at }. `active`: they hold paid
7150
+ * access to the app now; `renews`: a subscription will charge again;
7151
+ * `access_ends_at`: when access ends without a renewal (null while it has
7152
+ * no end, or when not held).
7153
+ * cancel() → Promise<{ active, renews: false, access_ends_at }> — stop the
7154
+ * subscription renewing. Access continues until `access_ends_at`, then
7155
+ * ends; nothing is refunded. Offer it whenever `renews` is true. Access
7156
+ * bought in the App Store or Google Play is cancelled in the store, so it
7157
+ * reads `renews: false` here.
7158
+ * refresh() → re-read the subscription.
7159
+ *
7160
+ * `error` and `cancel` rejections are a `PaymentError` — show its `message`
7161
+ * when `retryable` is false. Requires the `payments.charge:appUser` scope.
7162
+ */
7163
+ export function useAppSubscription() {
7164
+ const { state, setState, refresh, call } = usePaymentsResource(
7165
+ "useAppSubscription",
7166
+ "getAppSubscription",
7167
+ "The subscription is only available for an installed widget running in the app.",
7168
+ _appSubscriptionState,
7169
+ );
7170
+
7171
+ const cancel = useCallback(async () => {
7172
+ const res = await call("cancelAppSubscription");
7173
+ setState({ ..._appSubscriptionState(res), loading: false, error: null });
7174
+ return res;
7175
+ }, [call, setState]);
7176
+
7177
+ return { ...state, cancel, refresh };
7178
+ }
7179
+
7180
+ function _appSubscriptionState(subscription) {
7181
+ return { subscription };
7182
+ }
7183
+
7184
+ /**
7185
+ * The payments read hooks' shared core: `readMethod` loads the state on mount
7186
+ * and on `refresh()`, `toState` maps its result, and `call(method, ...args)`
7187
+ * invokes a payments method with its rejection as a `PaymentError`.
7188
+ */
7189
+ function usePaymentsResource(hookName, readMethod, unavailableMessage, toState) {
7190
+ const ctx = useWidgetContextOrThrow(hookName);
7108
7191
  const paymentsRef = useRef(ctx.payments);
7109
7192
  paymentsRef.current = ctx.payments;
7110
- const supported = Boolean(
7111
- ctx.payments && typeof ctx.payments.listAccessPlans === "function",
7112
- );
7193
+ const supported = Boolean(ctx.payments && typeof ctx.payments[readMethod] === "function");
7113
7194
  const [state, setState] = useState(() => ({
7114
- plans: [],
7195
+ ...toState(null),
7115
7196
  loading: supported,
7116
- error: supported
7117
- ? null
7118
- : new PaymentError(
7119
- "PAYMENTS_UNAVAILABLE",
7120
- "Plans are only available for an installed widget running in the app.",
7121
- ),
7122
- purchaseAvailable: false,
7197
+ error: supported ? null : new PaymentError("PAYMENTS_UNAVAILABLE", unavailableMessage),
7123
7198
  }));
7124
7199
 
7125
7200
  const refresh = useCallback(async () => {
7126
7201
  const payments = paymentsRef.current;
7127
- if (!payments || typeof payments.listAccessPlans !== "function") return;
7202
+ if (!payments || typeof payments[readMethod] !== "function") return;
7128
7203
  setState((prev) => ({ ...prev, loading: true }));
7129
7204
  try {
7130
- const res = await payments.listAccessPlans();
7131
- setState({
7132
- plans: Array.isArray(res && res.data) ? res.data : [],
7133
- loading: false,
7134
- error: null,
7135
- purchaseAvailable: Boolean(res && res.meta && res.meta.purchase_available),
7136
- });
7205
+ const res = await payments[readMethod]();
7206
+ setState({ ...toState(res), loading: false, error: null });
7137
7207
  } catch (err) {
7138
7208
  setState((prev) => ({ ...prev, loading: false, error: toPaymentError(err) }));
7139
7209
  }
7140
- }, []);
7210
+ }, [readMethod, toState]);
7141
7211
 
7142
7212
  useEffect(() => {
7143
7213
  if (supported) refresh();
7144
7214
  }, [supported, refresh]);
7145
7215
 
7146
- const purchase = useCallback(
7147
- async (planId, opts) => {
7148
- const payments = paymentsRef.current;
7149
- if (!payments || typeof payments.purchaseAccessPlan !== "function") {
7150
- throw new PaymentError(
7151
- "PAYMENTS_UNAVAILABLE",
7152
- "Plans are only available for an installed widget running in the app.",
7153
- );
7154
- }
7155
- let res;
7156
- try {
7157
- res = await payments.purchaseAccessPlan({
7158
- plan_id: planId,
7159
- return_path: opts && opts.return_path,
7160
- });
7161
- } catch (err) {
7162
- throw toPaymentError(err);
7163
- }
7164
- if (res && res.status === "ACTIVE") await refresh();
7165
- return res;
7166
- },
7167
- [refresh],
7168
- );
7169
-
7170
- const cancel = useCallback(
7171
- async (planId) => {
7216
+ const call = useCallback(
7217
+ async (method, ...args) => {
7172
7218
  const payments = paymentsRef.current;
7173
- if (!payments || typeof payments.cancelAccessPlan !== "function") {
7174
- throw new PaymentError(
7175
- "PAYMENTS_UNAVAILABLE",
7176
- "Plans are only available for an installed widget running in the app.",
7177
- );
7219
+ if (!payments || typeof payments[method] !== "function") {
7220
+ throw new PaymentError("PAYMENTS_UNAVAILABLE", unavailableMessage);
7178
7221
  }
7179
- let res;
7180
7222
  try {
7181
- res = await payments.cancelAccessPlan(planId);
7223
+ return await payments[method](...args);
7182
7224
  } catch (err) {
7183
7225
  throw toPaymentError(err);
7184
7226
  }
7185
- await refresh();
7186
- return res;
7187
7227
  },
7188
- [refresh],
7228
+ [unavailableMessage],
7189
7229
  );
7190
7230
 
7191
- return { ...state, purchase, cancel, refresh };
7231
+ return { state, setState, refresh, call };
7192
7232
  }
7193
7233
 
7194
7234
  /* ============================================================================
package/dist/index.d.ts CHANGED
@@ -713,6 +713,8 @@ export interface PaymentsClient {
713
713
  cancelAccessPlan(
714
714
  planId: string,
715
715
  ): Promise<{ plan_id: string; renews: false; access_ends_at: string | null }>;
716
+ getAppSubscription(): Promise<AppSubscription>;
717
+ cancelAppSubscription(): Promise<AppSubscription>;
716
718
  }
717
719
 
718
720
  /**
@@ -1610,6 +1612,31 @@ export interface AccessPlansApi {
1610
1612
  */
1611
1613
  export function useAccessPlans(): AccessPlansApi;
1612
1614
 
1615
+ /** The signed-in app user's own subscription to the app itself (a paid app). */
1616
+ export interface AppSubscription {
1617
+ /** They hold paid access to the app now. */
1618
+ active: boolean;
1619
+ /** A subscription will charge again; false once cancelled, or bought in a store. */
1620
+ renews: boolean;
1621
+ /** ISO time access ends without a renewal; null while it has no end, or when not held. */
1622
+ access_ends_at: string | null;
1623
+ }
1624
+
1625
+ export interface AppSubscriptionApi {
1626
+ subscription: AppSubscription | null;
1627
+ loading: boolean;
1628
+ error: PaymentError | null;
1629
+ /** Stop it renewing; access runs to `access_ends_at`, nothing is refunded. */
1630
+ cancel(): Promise<AppSubscription>;
1631
+ refresh(): Promise<void>;
1632
+ }
1633
+
1634
+ /**
1635
+ * The signed-in app user's subscription to a paid app, and the control to
1636
+ * cancel it themselves. Requires the `payments.charge:appUser` scope.
1637
+ */
1638
+ export function useAppSubscription(): AppSubscriptionApi;
1639
+
1613
1640
  /**
1614
1641
  * Rejection thrown by both `usePayments()` callbacks. `code` is the server's
1615
1642
  * own reason when it sent one; `message` is its user-safe sentence. Branch on
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ export {
68
68
  useWidgetInput,
69
69
  usePayments,
70
70
  useAccessPlans,
71
+ useAppSubscription,
71
72
  useWorkspaceCurrency,
72
73
  useSendNotification,
73
74
  useRecordMute,
@@ -68,6 +68,7 @@ export {
68
68
  useWidgetInput,
69
69
  usePayments,
70
70
  useAccessPlans,
71
+ useAppSubscription,
71
72
  useWorkspaceCurrency,
72
73
  useSendNotification,
73
74
  useRecordMute,
@@ -3,6 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  const { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } = require("./contract.cjs");
6
+ const { isPlainObject } = require("./plain-object.cjs");
6
7
 
7
8
  // REQ-NAV-STRUCTURE — the host side of an app's navigation SHAPE and the
8
9
  // tokens its footer strip is painted with.
@@ -27,10 +28,6 @@ const { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } = requi
27
28
  // existed renders and compiles byte-identically.
28
29
  const DEFAULT_MENU_TYPE = "sidebar";
29
30
 
30
- function isPlainObject(value) {
31
- return value !== null && typeof value === "object" && !Array.isArray(value);
32
- }
33
-
34
31
  /**
35
32
  * Resolve a `theme_config.navigation` block to the shape both hosts read.
36
33
  *
@@ -17,15 +17,12 @@
17
17
  // diverge.
18
18
 
19
19
  import { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } from "./contract.js";
20
+ import { isPlainObject } from "./plain-object.js";
20
21
 
21
22
  // Absent or unknown resolves here, so every app authored before menu types
22
23
  // existed renders and compiles byte-identically.
23
24
  const DEFAULT_MENU_TYPE = "sidebar";
24
25
 
25
- function isPlainObject(value) {
26
- return value !== null && typeof value === "object" && !Array.isArray(value);
27
- }
28
-
29
26
  /**
30
27
  * Resolve a `theme_config.navigation` block to the shape both hosts read.
31
28
  *
@@ -0,0 +1,11 @@
1
+ // GENERATED from plain-object.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change plain-object.js, then run `npm run generate:cjs` in packages/widget-sdk.
3
+ "use strict";
4
+
5
+ /** Any non-null, non-array object — a Date, Map or class instance passes. */
6
+ function isPlainObject(value) {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ module.exports = {
10
+ isPlainObject,
11
+ };
@@ -0,0 +1,4 @@
1
+ /** Any non-null, non-array object — a Date, Map or class instance passes. */
2
+ export function isPlainObject(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { CORNER_RADIUS_KEYS } from "./corner-radius.js";
5
5
  import { SPACING_KEYS } from "./spacing.js";
6
+ import { isPlainObject } from "./plain-object.js";
6
7
 
7
8
  const VALID_TYPES = new Set([
8
9
  "string", "number", "boolean",
@@ -85,10 +86,6 @@ const VALID_TYPES = new Set([
85
86
  /** The propertySchema field types, for documentation generated from this list. */
86
87
  export const PROPERTY_TYPES = Object.freeze([...VALID_TYPES]);
87
88
 
88
- function isPlainObject(v) {
89
- return v !== null && typeof v === "object" && !Array.isArray(v);
90
- }
91
-
92
89
  function validateDef(def, path, errors) {
93
90
  if (!isPlainObject(def)) {
94
91
  errors.push(`${path}: must be an object`);
@@ -2,6 +2,8 @@
2
2
  // a sign order's state, what a refusal says, and how an overview is indexed.
3
3
  // Kept apart from hooks.js so it runs under plain `node --test`.
4
4
 
5
+ import { isPlainObject } from "./plain-object.js";
6
+
5
7
  /** The server reads at most this many file ids per overview call. */
6
8
  export const OVERVIEW_IDS_PER_CALL = 200;
7
9
 
@@ -83,12 +85,11 @@ export function readRefusalDetail(err) {
83
85
  }
84
86
 
85
87
  const nonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
86
- const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
87
88
 
88
89
  /** One prepare entry as the filestore client takes it, or null when it is neither form. */
89
90
  function signerForPrepare(signer) {
90
91
  if (nonEmptyString(signer)) return signer;
91
- if (!isObject(signer)) return null;
92
+ if (!isPlainObject(signer)) return null;
92
93
  const keys = Object.keys(signer).sort().join(",");
93
94
  if (keys === "app_user_id" && nonEmptyString(signer.app_user_id)) return signer.app_user_id;
94
95
  if (keys === "email,name" && nonEmptyString(signer.name) && nonEmptyString(signer.email)) {
@@ -111,7 +112,7 @@ export function signerListForPrepare(signers) {
111
112
 
112
113
  /** The body of a new link: a corrected `email` when one is given, otherwise nothing. */
113
114
  export function resendLinkOptions(options) {
114
- const email = isObject(options) && typeof options.email === "string" ? options.email.trim() : "";
115
+ const email = isPlainObject(options) && typeof options.email === "string" ? options.email.trim() : "";
115
116
  return email ? { email } : {};
116
117
  }
117
118
 
@@ -3,6 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  const { isHexColor, APPEARANCE_PREFERENCES } = require("./contract.cjs");
6
+ const { isPlainObject } = require("./plain-object.cjs");
6
7
 
7
8
  // APPEARANCE_PREFERENCES: what an app opens on and a user may choose.
8
9
 
@@ -21,10 +22,6 @@ const APPEARANCE_THEME_KEYS = ["dark", "appearance"];
21
22
  /** The two palettes an app can render. */
22
23
  const APPEARANCE_MODES = Object.freeze(["light", "dark"]);
23
24
 
24
- function isPlainObject(value) {
25
- return value !== null && typeof value === "object" && !Array.isArray(value);
26
- }
27
-
28
25
  // A gradient is one colour value: its angle and type travel with its stops.
29
26
  function isGradient(value) {
30
27
  return isPlainObject(value) && isHexColor(value.from) && isHexColor(value.to);
@@ -1,5 +1,6 @@
1
1
  // APPEARANCE_PREFERENCES: what an app opens on and a user may choose.
2
2
  import { isHexColor, APPEARANCE_PREFERENCES } from "./contract.js";
3
+ import { isPlainObject } from "./plain-object.js";
3
4
 
4
5
  export { APPEARANCE_PREFERENCES };
5
6
 
@@ -17,10 +18,6 @@ const APPEARANCE_THEME_KEYS = ["dark", "appearance"];
17
18
  /** The two palettes an app can render. */
18
19
  export const APPEARANCE_MODES = Object.freeze(["light", "dark"]);
19
20
 
20
- function isPlainObject(value) {
21
- return value !== null && typeof value === "object" && !Array.isArray(value);
22
- }
23
-
24
21
  // A gradient is one colour value: its angle and type travel with its stops.
25
22
  function isGradient(value) {
26
23
  return isPlainObject(value) && isHexColor(value.from) && isHexColor(value.to);
@@ -3,6 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
6
+ const { isPlainObject } = require("./plain-object.cjs");
6
7
 
7
8
  // REQ-THEME-15 (sc-1497) — the host side of per-component style tokens.
8
9
  //
@@ -26,10 +27,6 @@ const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
26
27
  // must never drop a colour the runner already accepted.
27
28
  const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
28
29
 
29
- function isPlainObject(value) {
30
- return value !== null && typeof value === "object" && !Array.isArray(value);
31
- }
32
-
33
30
  function coerceToken(def, value) {
34
31
  if (def.type === "color") {
35
32
  if (typeof value !== "string") return undefined;
@@ -16,15 +16,12 @@
16
16
  // literal, so what a planner may emit and what a host applies cannot diverge.
17
17
 
18
18
  import { CONTRACT, normaliseComponentGradient } from "./contract.js";
19
+ import { isPlainObject } from "./plain-object.js";
19
20
 
20
21
  // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
21
22
  // must never drop a colour the runner already accepted.
22
23
  const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
23
24
 
24
- function isPlainObject(value) {
25
- return value !== null && typeof value === "object" && !Array.isArray(value);
26
- }
27
-
28
25
  function coerceToken(def, value) {
29
26
  if (def.type === "color") {
30
27
  if (typeof value !== "string") return undefined;
@@ -23,6 +23,7 @@ import {
23
23
  withHexAlpha,
24
24
  THEME_TOKEN_REF_PREFIX,
25
25
  } from "./contract.js";
26
+ import { isPlainObject } from "./plain-object.js";
26
27
 
27
28
  // A value is a binding, not a colour, when it carries this prefix. No CSS
28
29
  // colour can begin with it, so a stored literal can never be misread as one.
@@ -67,10 +68,6 @@ export function resolveThemeTokenRef(value, theme) {
67
68
  return ref.alpha === null ? resolved : withHexAlpha(resolved, ref.alpha);
68
69
  }
69
70
 
70
- function isPlainObject(value) {
71
- return value !== null && typeof value === "object" && !Array.isArray(value);
72
- }
73
-
74
71
  // The same guard the neighbouring style validators apply: these props are
75
72
  // author-stored JSON, and assigning such a key while rebuilding an object would
76
73
  // silently reseat its prototype instead of carrying the value.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.285.0",
3
+ "version": "0.287.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -2403,6 +2403,22 @@ const HOOKS = [
2403
2403
  ],
2404
2404
  scopes: ["payments.charge:appUser"],
2405
2405
  },
2406
+ // sc-15342 — a paid app's subscriber cancels their own subscription.
2407
+ {
2408
+ name: "useAppSubscription",
2409
+ signature: "useAppSubscription()",
2410
+ returnShape: {
2411
+ subscription:
2412
+ "{ active, renews, access_ends_at } | null // the signed-in user's subscription to the app itself (a paid app): active = they hold paid access now; renews = a subscription will charge again (false once cancelled, or when bought in the App Store / Google Play); access_ends_at = when access ends without a renewal (null while it has no end, or when not held)",
2413
+ loading: "boolean",
2414
+ error: "PaymentError | null",
2415
+ cancel:
2416
+ "() => Promise<{ active, renews: false, access_ends_at }> // stop it renewing; access runs to access_ends_at, nothing is refunded — offer it whenever renews is true",
2417
+ refresh: "() => Promise<void>",
2418
+ },
2419
+ requiredContextSlice: ["payments.getAppSubscription", "payments.cancelAppSubscription"],
2420
+ scopes: ["payments.charge:appUser"],
2421
+ },
2406
2422
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2407
2423
  // render money. A widget must NOT name a currency: the owner can change it
2408
2424
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3941,6 +3957,9 @@ const ACTION_SCRIPT_GLOBALS = [
3941
3957
  "directory",
3942
3958
  "console",
3943
3959
  "record",
3960
+ // sc-15609 — on record_updated, the row before the edit and what changed.
3961
+ "previous",
3962
+ "changedFields",
3944
3963
  // sc-5366 — `{ body }` on a run fired by the inbound `http_post` trigger,
3945
3964
  // null on every other trigger.
3946
3965
  "request",
@@ -4448,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4448
4467
  },
4449
4468
  payments: {
4450
4469
  description:
4451
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4470
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4452
4471
  required: true,
4453
4472
  fields: {
4454
4473
  requestPayment: "function",
@@ -4456,6 +4475,8 @@ const WIDGET_CONTEXT_SHAPE = {
4456
4475
  listAccessPlans: "function",
4457
4476
  purchaseAccessPlan: "function",
4458
4477
  cancelAccessPlan: "function",
4478
+ getAppSubscription: "function",
4479
+ cancelAppSubscription: "function",
4459
4480
  },
4460
4481
  },
4461
4482
  // sc-890 — backs useSendNotification().
@@ -6715,7 +6736,12 @@ const CONTRACT = deepFreeze({
6715
6736
  // "Rows per page" property.
6716
6737
  // 1.233.0: additive (sc-8115) — a `datastoreTemplate` table may declare
6717
6738
  // `deleteWhenLastMemberLeaves: true`.
6718
- version: "1.233.0",
6739
+ // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6740
+ // `changedFields`; no widget hook, primitive or manifest field changed.
6741
+ // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6742
+ // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6743
+ // app's subscriber cancels their own subscription.
6744
+ version: "1.235.0",
6719
6745
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6720
6746
  hooks: HOOKS,
6721
6747
  primitives: PRIMITIVES,