@colixsystems/widget-sdk 0.286.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,11 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
89
90
 
90
91
  ## Status
91
92
 
92
- `v0.286.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).
93
98
 
94
99
  ### What's new in 0.286.0 (contract 1.234.0)
95
100
 
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
@@ -4455,7 +4471,7 @@ const WIDGET_CONTEXT_SHAPE = {
4455
4471
  },
4456
4472
  payments: {
4457
4473
  description:
4458
- "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.",
4459
4475
  required: true,
4460
4476
  fields: {
4461
4477
  requestPayment: "function",
@@ -4463,6 +4479,8 @@ const WIDGET_CONTEXT_SHAPE = {
4463
4479
  listAccessPlans: "function",
4464
4480
  purchaseAccessPlan: "function",
4465
4481
  cancelAccessPlan: "function",
4482
+ getAppSubscription: "function",
4483
+ cancelAppSubscription: "function",
4466
4484
  },
4467
4485
  },
4468
4486
  // sc-890 — backs useSendNotification().
@@ -6724,7 +6742,10 @@ const CONTRACT = deepFreeze({
6724
6742
  // `deleteWhenLastMemberLeaves: true`.
6725
6743
  // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6726
6744
  // `changedFields`; no widget hook, primitive or manifest field changed.
6727
- version: "1.234.0",
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",
6728
6749
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6729
6750
  hooks: HOOKS,
6730
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
@@ -4451,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4451
4467
  },
4452
4468
  payments: {
4453
4469
  description:
4454
- "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.",
4455
4471
  required: true,
4456
4472
  fields: {
4457
4473
  requestPayment: "function",
@@ -4459,6 +4475,8 @@ const WIDGET_CONTEXT_SHAPE = {
4459
4475
  listAccessPlans: "function",
4460
4476
  purchaseAccessPlan: "function",
4461
4477
  cancelAccessPlan: "function",
4478
+ getAppSubscription: "function",
4479
+ cancelAppSubscription: "function",
4462
4480
  },
4463
4481
  },
4464
4482
  // sc-890 — backs useSendNotification().
@@ -6720,7 +6738,10 @@ const CONTRACT = deepFreeze({
6720
6738
  // `deleteWhenLastMemberLeaves: true`.
6721
6739
  // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6722
6740
  // `changedFields`; no widget hook, primitive or manifest field changed.
6723
- version: "1.234.0",
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",
6724
6745
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6725
6746
  hooks: HOOKS,
6726
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.286.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
@@ -4451,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4451
4467
  },
4452
4468
  payments: {
4453
4469
  description:
4454
- "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.",
4455
4471
  required: true,
4456
4472
  fields: {
4457
4473
  requestPayment: "function",
@@ -4459,6 +4475,8 @@ const WIDGET_CONTEXT_SHAPE = {
4459
4475
  listAccessPlans: "function",
4460
4476
  purchaseAccessPlan: "function",
4461
4477
  cancelAccessPlan: "function",
4478
+ getAppSubscription: "function",
4479
+ cancelAppSubscription: "function",
4462
4480
  },
4463
4481
  },
4464
4482
  // sc-890 — backs useSendNotification().
@@ -6720,7 +6738,10 @@ const CONTRACT = deepFreeze({
6720
6738
  // `deleteWhenLastMemberLeaves: true`.
6721
6739
  // 1.234.0: additive (sc-15609) — ACTION_SCRIPT_GLOBALS gains `previous` and
6722
6740
  // `changedFields`; no widget hook, primitive or manifest field changed.
6723
- version: "1.234.0",
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",
6724
6745
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6725
6746
  hooks: HOOKS,
6726
6747
  primitives: PRIMITIVES,