Package not found. Please check the package name and try again.

@sense-apps/mantle 0.1.0 → 0.2.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
@@ -1,14 +1,14 @@
1
- # @mantlekit/sdk
1
+ # @sense-apps/mantle
2
2
 
3
- Backend-only client your Shopify app uses to talk to your Mantlekit deployment. It deliberately mirrors Mantle's `@heymantle/client` (`MantleClient`) — same constructor shape, method names, and `customer.plan` / `customer.subscription` / `customer.features` nesting — so moving an app off Mantle is close to a find-and-replace.
3
+ Backend-only client your Shopify app uses to talk to your Mantlekit deployment. Response shapes (`Customer` / `Subscription` / `Plan` / `Feature`) are aligned field-for-field with Mantle's `@heymantle/client@1.1.51` for everything Mantlekit supports — same nesting, same field names, same helper semantics — so Mantle-era code like `customer.subscription?.plan?.features["unlimited_bars"]?.value` ports unchanged.
4
4
 
5
5
  **Server-side only.** Your frontend never talks to Mantlekit directly; it talks to your own backend, which uses this client and relays whatever your frontend needs.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- bun add @mantlekit/sdk
11
- # or: npm install @mantlekit/sdk
10
+ bun add @sense-apps/mantle
11
+ # or: npm install @sense-apps/mantle
12
12
  ```
13
13
 
14
14
  ## Configure
@@ -23,7 +23,7 @@ MANTLEKIT_API_KEY=mk_... # per-app API key, created in the Mantlekit dashboard
23
23
  Then construct with no arguments:
24
24
 
25
25
  ```ts
26
- import { MantleKitClient } from "@mantlekit/sdk";
26
+ import { MantleKitClient } from "@sense-apps/mantle";
27
27
 
28
28
  const admin = new MantleKitClient(); // reads MANTLEKIT_URL + MANTLEKIT_API_KEY
29
29
  ```
@@ -66,52 +66,74 @@ const { shopId, apiToken } = await admin.identify({
66
66
  const client = new MantleKitClient({ customerApiToken: shop.mantlekitToken });
67
67
  ```
68
68
 
69
- ### 3. Pricing page
69
+ ### 3. Read the customer — Mantle's exact nesting
70
+
71
+ ```ts
72
+ const customer = await client.getCustomer();
73
+ const subscription = customer?.subscription || null;
74
+
75
+ if (subscription?.plan) {
76
+ SetPlanMetafields(shopifyClient, subscription.plan); // full Plan: total, interval, features, …
77
+ }
78
+
79
+ const isPremiumUser = subscription?.plan?.features["unlimited_bars"]?.value === true;
80
+ const maxBarViews = (subscription?.plan?.features["bar_views"]?.value as number) || 2500;
81
+ ```
82
+
83
+ `Customer` also carries `plans` (the eligible-plan list, same as `getPlans()`), `features` (resolved for the shop — falls back to each feature's default when there's no subscription), and `billingStatus` (`"none" | "active" | "trialing" | "canceled" | "frozen"`).
84
+
85
+ ### 4. Pricing page
70
86
 
71
87
  ```ts
72
88
  const plans = await client.getPlans();
73
- // ResolvedPlan[]: planId, name, amount (string money is never a float),
74
- // currencyCode, trialDays, viaOverride, features
89
+ // Mantle Plan shape: id, name, total, subtotal, interval ("EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY"),
90
+ // trialDays, currencyCode, features, featuresOrder, flexBilling, …
75
91
  ```
76
92
 
77
- Eligibility (tag rules, per-shop overrides) is resolved server-side by Mantlekit — the list you get back is exactly what this shop may buy.
93
+ Eligibility (tag rules, per-shop price/trial overrides) is resolved server-side — the list is exactly what this shop may buy, with override pricing already applied. Money fields (`total`, `subtotal`) are numbers only because that is Mantle's wire shape — treat them as display values.
78
94
 
79
- ### 4. Subscribe / upgrade
95
+ ### 5. Subscribe / upgrade
80
96
 
81
97
  ```ts
82
- const { confirmationUrl } = await client.subscribe({
98
+ const subscription = await client.subscribe({
83
99
  planId,
84
100
  returnUrl: `https://your-app.com/billing/confirm?shop=${shop.domain}`,
85
101
  });
86
- // redirect the merchant's browser to confirmationUrl (Shopify approval screen)
102
+ // redirect the merchant's browser to Shopify's approval screen:
103
+ redirect(subscription.confirmationUrl!);
87
104
  ```
88
105
 
89
- The subscription only becomes active after the merchant approves and Shopify's webhook lands back in Mantlekit — don't grant features on redirect alone; check `getCustomer()`.
106
+ The subscription only becomes active after the merchant approves and Shopify confirms — don't grant features on redirect alone; check `getCustomer()`.
90
107
 
91
- ### 5. Gate features
108
+ ### 6. Gate features
92
109
 
93
110
  ```ts
94
- const customer = await client.getCustomer();
95
- // { id, myshopifyDomain, plan, subscription: { status, trialEndsAt, ... }, features }
111
+ if (await client.isFeatureEnabled({ featureKey: "unlimited_bars" })) { ... }
96
112
 
97
- if (await client.isFeatureEnabled("advanced_reports")) { ... }
98
- const maxSeats = await client.limitForFeature("team_seats"); // number | null
113
+ // limit features evaluate as `count < limit`, -1 = unlimited — Mantle's semantics:
114
+ const canAddBar = await client.isFeatureEnabled({ featureKey: "bars", count: currentBarCount });
115
+
116
+ const maxSeats = await client.limitForFeature({ featureKey: "team_seats" }); // -1 if not a limit feature
99
117
  ```
100
118
 
101
- `isFeatureEnabled` / `limitForFeature` each call `getCustomer()` under the hood — if you need several flags in one request, call `getCustomer()` once and read `customer.features` yourself.
119
+ Both helpers also accept a bare string key. Each one calls `getCustomer()` under the hood — if you need several flags in one request, call `getCustomer()` once and read `customer.features` yourself.
102
120
 
103
- ### 6. Cancel
121
+ ### 7. Cancel
104
122
 
105
123
  ```ts
106
- await client.cancelSubscription();
124
+ const cancelled = await client.cancelSubscription(); // resolves to the cancelled Subscription
107
125
  ```
108
126
 
127
+ ## What's intentionally empty
128
+
129
+ Mantle features Mantlekit doesn't implement are emitted as empty values rather than omitted, so ported code never crashes on `.length`/`?.`: `subscription.lineItems`, `plan.discounts`, `customer.usage`, `customer.usageCredits`, `customer.reviews`, `customer.paymentMethod`, `customFields`. There are no client methods for Stripe billing, invoices, notifications, or checklists.
130
+
109
131
  ## Errors
110
132
 
111
133
  Every non-2xx response throws `MantleKitError` with `status` and, for validation failures, `userErrors: { field, message }[]`:
112
134
 
113
135
  ```ts
114
- import { MantleKitError } from "@mantlekit/sdk";
136
+ import { MantleKitError } from "@sense-apps/mantle";
115
137
 
116
138
  try {
117
139
  await client.subscribe({ planId, returnUrl });
package/dist/client.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
- * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
- * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
2
+ * @sense-apps/mantle — BACKEND-ONLY client library a merchant's OWN Shopify
3
+ * app uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
4
  * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
5
  * YOUR domain, since you own the whole stack (no third party in the loop).
6
- * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
- * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
- * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
- * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
- * so porting off Mantle is close to a drop-in swap.
6
+ *
7
+ * Response shapes (Customer / Subscription / Plan / Feature) are aligned
8
+ * field-for-field with @heymantle/client@1.1.51 for everything Mantlekit
9
+ * supports, so Mantle-era code like
10
+ * `customer.subscription?.plan?.features["unlimited_bars"]?.value` ports
11
+ * unchanged. Mantle features Mantlekit doesn't have (Stripe billing,
12
+ * invoices, notifications, checklists, usage metrics reporting) are emitted
13
+ * as empty values on the payloads and have no client methods.
11
14
  *
12
15
  * This package has NO frontend/browser usage — every call (including the
13
16
  * customerApiToken-scoped ones) is meant to run on your own server. Your
@@ -34,42 +37,106 @@
34
37
  * 3. Store `apiToken` against that shop in your own DB.
35
38
  * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
39
  * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
- * const plans = await client.getPlans();
38
- * const customer = await client.getCustomer(); // { plan, subscription, features }
40
+ * const customer = await client.getCustomer();
41
+ * const plan = customer.subscription?.plan ?? null; // full Mantle-shaped Plan
39
42
  * // ...then your backend returns whatever subset of this your frontend needs.
40
43
  */
41
- export type FeatureType = "boolean" | "limit" | "limit_overage" | "string";
42
- export interface ResolvedFeature {
44
+ /** Mantle spells Mantlekit's "limit_overage" as "limit_with_overage"; the API maps it on the way out. */
45
+ export type FeatureType = "boolean" | "limit" | "limit_with_overage" | "string";
46
+ export interface Feature {
47
+ id: string;
43
48
  key: string;
44
49
  name: string;
45
50
  type: FeatureType;
51
+ description: string | null;
46
52
  value: boolean | number | string;
53
+ displayOrder: number;
47
54
  }
48
- export interface ResolvedPlan {
49
- planId: string;
55
+ export interface UsageCharge {
56
+ id: string;
57
+ amount: number;
58
+ type: "unit";
59
+ terms: string;
60
+ cappedAmount: number;
61
+ }
62
+ export interface Plan {
63
+ id: string;
50
64
  name: string;
51
- amount: string;
65
+ description: string | null;
66
+ availability: "public" | "customer" | "hidden";
67
+ type: "base";
52
68
  currencyCode: string;
69
+ presentmentAmount: number;
70
+ presentmentCurrencyCode: string;
71
+ /** Amount after discounts. Mantlekit has no discounts yet, so total === subtotal. */
72
+ total: number;
73
+ subtotal: number;
74
+ /** @deprecated Mantle deprecates this in favor of `subtotal`; kept for drop-in compat. */
75
+ amount: number;
76
+ public: boolean;
77
+ visible: boolean;
78
+ eligible: boolean;
53
79
  trialDays: number;
54
- viaOverride: boolean;
55
- features: Record<string, ResolvedFeature>;
80
+ interval: "EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY";
81
+ features: Record<string, Feature>;
82
+ featuresOrder: string[];
83
+ usageCharges: UsageCharge[];
84
+ usageChargeCappedAmount?: number;
85
+ customFields: Record<string, unknown>;
86
+ discounts: never[];
87
+ flexBilling: boolean;
88
+ flexBillingTerms: string | null;
89
+ autoUpgradeToPlanId: string | null;
90
+ createdAt?: string;
56
91
  }
57
- export interface ShopCustomer {
92
+ export interface Subscription {
93
+ id: string;
94
+ plan: Plan;
95
+ lineItems: never[];
96
+ active: boolean;
97
+ activatedAt: string | null;
98
+ billingCycleAnchor: string | null;
99
+ currentPeriodStart: string | null;
100
+ currentPeriodEnd: string | null;
101
+ trialStartsAt: string | null;
102
+ trialExpiresAt: string | null;
103
+ cancelledAt: string | null;
104
+ createdAt: string | null;
105
+ features: Record<string, Feature>;
106
+ featuresOrder: string[];
107
+ usageCharges: UsageCharge[];
108
+ usageChargeCappedAmount?: number;
109
+ total: number;
110
+ subtotal: number;
111
+ presentmentTotal: number;
112
+ presentmentSubtotal: number;
113
+ /** Present on subscribe() responses — redirect the merchant's browser here. */
114
+ confirmationUrl?: string;
115
+ returnUrl?: string;
116
+ /** Mantlekit extra (not in Mantle's shape): raw status, e.g. "pending" | "active" | "cancelled". */
117
+ status: string;
118
+ }
119
+ export interface Customer {
58
120
  id: string;
59
- myshopifyDomain: string;
60
121
  name: string | null;
61
122
  email: string | null;
123
+ test: boolean;
124
+ platform: "shopify";
125
+ platformId?: string;
126
+ myshopifyDomain: string;
62
127
  installedAt: string | null;
63
- plan: {
64
- id: string;
65
- name: string;
66
- } | null;
67
- subscription: {
68
- status: string;
69
- trialEndsAt: string | null;
70
- currentPeriodEnd: string | null;
71
- };
72
- features: Record<string, ResolvedFeature>;
128
+ trialStartsAt: string | null;
129
+ trialExpiresAt: string | null;
130
+ /** The plans this shop is eligible for — same list as getPlans(). */
131
+ plans: Plan[];
132
+ subscription: Subscription | null;
133
+ paymentMethod: null;
134
+ features: Record<string, Feature>;
135
+ usage: Record<string, never>;
136
+ customFields: Record<string, unknown>;
137
+ usageCredits: never[];
138
+ reviews: never[];
139
+ billingStatus: "none" | "active" | "trialing" | "canceled" | "frozen";
73
140
  }
74
141
  export interface IdentifyParams {
75
142
  myshopifyDomain: string;
@@ -91,14 +158,6 @@ export interface SubscribeParams {
91
158
  returnUrl: string;
92
159
  test?: boolean;
93
160
  }
94
- export interface SubscribeResult {
95
- subscriptionId: string;
96
- confirmationUrl: string;
97
- }
98
- export interface CancelSubscriptionResult {
99
- id: string;
100
- status: string;
101
- }
102
161
  export declare class MantleKitError extends Error {
103
162
  status: number;
104
163
  userErrors?: Array<{
@@ -136,17 +195,35 @@ export declare class MantleKitClient {
136
195
  /** Register (or re-sync) a shop after your app's own Shopify OAuth completes. Requires apiKey. */
137
196
  identify(params: IdentifyParams): Promise<IdentifyResult>;
138
197
  /** Plans this shop is eligible for — what your pricing page renders. Requires customerApiToken. */
139
- getPlans(): Promise<ResolvedPlan[]>;
140
- /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
141
- getCustomer(): Promise<ShopCustomer>;
142
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
143
- isFeatureEnabled(featureKey: string): Promise<boolean>;
144
- /** Convenience wrapper over getCustomer().features mirrors MantleClient.limitForFeature(). */
145
- limitForFeature(featureKey: string): Promise<number | null>;
146
- /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
147
- subscribe(params: SubscribeParams): Promise<SubscribeResult>;
148
- /** Cancel the shop's active subscription. Requires customerApiToken. */
149
- cancelSubscription(): Promise<CancelSubscriptionResult>;
198
+ getPlans(): Promise<Plan[]>;
199
+ /** The shop's own record — Mantle Customer shape: { plans, subscription: { plan, ... }, features, billingStatus }. */
200
+ getCustomer(): Promise<Customer>;
201
+ /**
202
+ * Mirrors MantleClient.isFeatureEnabled({ featureKey, count }) — limits
203
+ * evaluate as `count < limit` (-1 = unlimited). A bare string argument is
204
+ * also accepted: isFeatureEnabled("unlimited_bars").
205
+ */
206
+ isFeatureEnabled(params: string | {
207
+ featureKey: string;
208
+ count?: number;
209
+ }): Promise<boolean>;
210
+ /**
211
+ * Mirrors MantleClient.limitForFeature({ featureKey }) — resolves to -1
212
+ * when the feature is missing or not a limit type, exactly like Mantle.
213
+ */
214
+ limitForFeature(params: string | {
215
+ featureKey: string;
216
+ }): Promise<number>;
217
+ /**
218
+ * Start checkout for a plan. Resolves to the pending Subscription —
219
+ * redirect the merchant's browser to `subscription.confirmationUrl`.
220
+ * Requires customerApiToken.
221
+ */
222
+ subscribe(params: SubscribeParams): Promise<Subscription>;
223
+ /** Cancel the shop's active subscription. Resolves to the cancelled Subscription. Requires customerApiToken. */
224
+ cancelSubscription(params?: {
225
+ cancelReason?: string;
226
+ }): Promise<Subscription>;
150
227
  private customerHeaders;
151
228
  private request;
152
229
  }
package/dist/client.js CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
- * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
- * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
2
+ * @sense-apps/mantle — BACKEND-ONLY client library a merchant's OWN Shopify
3
+ * app uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
4
  * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
5
  * YOUR domain, since you own the whole stack (no third party in the loop).
6
- * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
- * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
- * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
- * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
- * so porting off Mantle is close to a drop-in swap.
6
+ *
7
+ * Response shapes (Customer / Subscription / Plan / Feature) are aligned
8
+ * field-for-field with @heymantle/client@1.1.51 for everything Mantlekit
9
+ * supports, so Mantle-era code like
10
+ * `customer.subscription?.plan?.features["unlimited_bars"]?.value` ports
11
+ * unchanged. Mantle features Mantlekit doesn't have (Stripe billing,
12
+ * invoices, notifications, checklists, usage metrics reporting) are emitted
13
+ * as empty values on the payloads and have no client methods.
11
14
  *
12
15
  * This package has NO frontend/browser usage — every call (including the
13
16
  * customerApiToken-scoped ones) is meant to run on your own server. Your
@@ -34,8 +37,8 @@
34
37
  * 3. Store `apiToken` against that shop in your own DB.
35
38
  * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
39
  * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
- * const plans = await client.getPlans();
38
- * const customer = await client.getCustomer(); // { plan, subscription, features }
40
+ * const customer = await client.getCustomer();
41
+ * const plan = customer.subscription?.plan ?? null; // full Mantle-shaped Plan
39
42
  * // ...then your backend returns whatever subset of this your frontend needs.
40
43
  */
41
44
  export class MantleKitError extends Error {
@@ -49,6 +52,22 @@ export class MantleKitError extends Error {
49
52
  }
50
53
  }
51
54
  const env = (key) => typeof process !== "undefined" ? process.env?.[key] : undefined;
55
+ /**
56
+ * Mirrors MantleClient's private evaluateFeature(): booleans by value,
57
+ * limits by `count < value` with -1 meaning unlimited, overage limits
58
+ * always enabled (they never block, they bill).
59
+ */
60
+ function evaluateFeature(feature, count = 0) {
61
+ if (!feature)
62
+ return false;
63
+ if (feature.type === "limit") {
64
+ const limit = Number(feature.value);
65
+ return limit === -1 || count < limit;
66
+ }
67
+ if (feature.type === "limit_with_overage")
68
+ return true;
69
+ return Boolean(feature.value);
70
+ }
52
71
  export class MantleKitClient {
53
72
  baseUrl;
54
73
  apiKey;
@@ -72,28 +91,43 @@ export class MantleKitClient {
72
91
  getPlans() {
73
92
  return this.request("GET", "/sdk/v1/plans", this.customerHeaders());
74
93
  }
75
- /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
94
+ /** The shop's own record — Mantle Customer shape: { plans, subscription: { plan, ... }, features, billingStatus }. */
76
95
  getCustomer() {
77
96
  return this.request("GET", "/sdk/v1/customer", this.customerHeaders());
78
97
  }
79
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
80
- async isFeatureEnabled(featureKey) {
98
+ /**
99
+ * Mirrors MantleClient.isFeatureEnabled({ featureKey, count }) — limits
100
+ * evaluate as `count < limit` (-1 = unlimited). A bare string argument is
101
+ * also accepted: isFeatureEnabled("unlimited_bars").
102
+ */
103
+ async isFeatureEnabled(params) {
104
+ const { featureKey, count = 0 } = typeof params === "string" ? { featureKey: params, count: 0 } : params;
81
105
  const customer = await this.getCustomer();
82
- return Boolean(customer.features[featureKey]?.value);
106
+ return evaluateFeature(customer.features[featureKey], count);
83
107
  }
84
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
85
- async limitForFeature(featureKey) {
108
+ /**
109
+ * Mirrors MantleClient.limitForFeature({ featureKey }) — resolves to -1
110
+ * when the feature is missing or not a limit type, exactly like Mantle.
111
+ */
112
+ async limitForFeature(params) {
113
+ const featureKey = typeof params === "string" ? params : params.featureKey;
86
114
  const customer = await this.getCustomer();
87
- const value = customer.features[featureKey]?.value;
88
- return typeof value === "number" ? value : null;
115
+ const feature = customer.features[featureKey];
116
+ if (!feature || (feature.type !== "limit" && feature.type !== "limit_with_overage"))
117
+ return -1;
118
+ return Number(feature.value);
89
119
  }
90
- /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
120
+ /**
121
+ * Start checkout for a plan. Resolves to the pending Subscription —
122
+ * redirect the merchant's browser to `subscription.confirmationUrl`.
123
+ * Requires customerApiToken.
124
+ */
91
125
  subscribe(params) {
92
126
  return this.request("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
93
127
  }
94
- /** Cancel the shop's active subscription. Requires customerApiToken. */
95
- cancelSubscription() {
96
- return this.request("POST", "/sdk/v1/cancel-subscription", this.customerHeaders());
128
+ /** Cancel the shop's active subscription. Resolves to the cancelled Subscription. Requires customerApiToken. */
129
+ cancelSubscription(params) {
130
+ return this.request("POST", "/sdk/v1/cancel-subscription", this.customerHeaders(), params ?? {});
97
131
  }
98
132
  customerHeaders() {
99
133
  if (!this.customerApiToken)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sense-apps/mantle",
3
- "version": "0.1.0",
4
- "description": "Backend-only client for a self-hosted Mantlekit deployment — MantleClient-compatible API (identify, getPlans, getCustomer, subscribe) for Shopify apps.",
3
+ "version": "0.2.0",
4
+ "description": "Backend-only client for a self-hosted Mantlekit deployment — @heymantle/client-compatible shapes (Customer/Subscription/Plan/Feature) and API (identify, getPlans, getCustomer, subscribe) for Shopify apps.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
package/src/client.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
- * @mantlekit/sdk — BACKEND-ONLY client library a merchant's OWN Shopify app
3
- * uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
2
+ * @sense-apps/mantle — BACKEND-ONLY client library a merchant's OWN Shopify
3
+ * app uses to talk to a Mantlekit deployment, the same way `@heymantle/client`
4
4
  * (MantleClient) talks to api.heymantle.com — except `baseUrl` points at
5
5
  * YOUR domain, since you own the whole stack (no third party in the loop).
6
- * Constructor shape, method names (`identify`, `getCustomer`, `getPlans`,
7
- * `isFeatureEnabled`, `limitForFeature`), and the `customer.features`/
8
- * `customer.plan`/`customer.subscription` nesting deliberately mirror
9
- * MantleClient (see appapi.heymantle.dev/docs/understanding-mantleclient)
10
- * so porting off Mantle is close to a drop-in swap.
6
+ *
7
+ * Response shapes (Customer / Subscription / Plan / Feature) are aligned
8
+ * field-for-field with @heymantle/client@1.1.51 for everything Mantlekit
9
+ * supports, so Mantle-era code like
10
+ * `customer.subscription?.plan?.features["unlimited_bars"]?.value` ports
11
+ * unchanged. Mantle features Mantlekit doesn't have (Stripe billing,
12
+ * invoices, notifications, checklists, usage metrics reporting) are emitted
13
+ * as empty values on the payloads and have no client methods.
11
14
  *
12
15
  * This package has NO frontend/browser usage — every call (including the
13
16
  * customerApiToken-scoped ones) is meant to run on your own server. Your
@@ -34,39 +37,112 @@
34
37
  * 3. Store `apiToken` against that shop in your own DB.
35
38
  * 4. On any request from YOUR frontend (e.g. your pricing page's data loader):
36
39
  * const client = new MantleKitClient({ customerApiToken: storedApiToken });
37
- * const plans = await client.getPlans();
38
- * const customer = await client.getCustomer(); // { plan, subscription, features }
40
+ * const customer = await client.getCustomer();
41
+ * const plan = customer.subscription?.plan ?? null; // full Mantle-shaped Plan
39
42
  * // ...then your backend returns whatever subset of this your frontend needs.
40
43
  */
41
44
 
42
- export type FeatureType = "boolean" | "limit" | "limit_overage" | "string";
45
+ /** Mantle spells Mantlekit's "limit_overage" as "limit_with_overage"; the API maps it on the way out. */
46
+ export type FeatureType = "boolean" | "limit" | "limit_with_overage" | "string";
43
47
 
44
- export interface ResolvedFeature {
48
+ export interface Feature {
49
+ id: string;
45
50
  key: string;
46
51
  name: string;
47
52
  type: FeatureType;
53
+ description: string | null;
48
54
  value: boolean | number | string;
55
+ displayOrder: number;
49
56
  }
50
57
 
51
- export interface ResolvedPlan {
52
- planId: string;
58
+ export interface UsageCharge {
59
+ id: string;
60
+ amount: number;
61
+ type: "unit";
62
+ terms: string;
63
+ cappedAmount: number;
64
+ }
65
+
66
+ export interface Plan {
67
+ id: string;
53
68
  name: string;
54
- amount: string;
69
+ description: string | null;
70
+ availability: "public" | "customer" | "hidden";
71
+ type: "base";
55
72
  currencyCode: string;
73
+ presentmentAmount: number;
74
+ presentmentCurrencyCode: string;
75
+ /** Amount after discounts. Mantlekit has no discounts yet, so total === subtotal. */
76
+ total: number;
77
+ subtotal: number;
78
+ /** @deprecated Mantle deprecates this in favor of `subtotal`; kept for drop-in compat. */
79
+ amount: number;
80
+ public: boolean;
81
+ visible: boolean;
82
+ eligible: boolean;
56
83
  trialDays: number;
57
- viaOverride: boolean;
58
- features: Record<string, ResolvedFeature>;
84
+ interval: "EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY";
85
+ features: Record<string, Feature>;
86
+ featuresOrder: string[];
87
+ usageCharges: UsageCharge[];
88
+ usageChargeCappedAmount?: number;
89
+ customFields: Record<string, unknown>;
90
+ discounts: never[];
91
+ flexBilling: boolean;
92
+ flexBillingTerms: string | null;
93
+ autoUpgradeToPlanId: string | null;
94
+ createdAt?: string;
59
95
  }
60
96
 
61
- export interface ShopCustomer {
97
+ export interface Subscription {
98
+ id: string;
99
+ plan: Plan;
100
+ lineItems: never[];
101
+ active: boolean;
102
+ activatedAt: string | null;
103
+ billingCycleAnchor: string | null;
104
+ currentPeriodStart: string | null;
105
+ currentPeriodEnd: string | null;
106
+ trialStartsAt: string | null;
107
+ trialExpiresAt: string | null;
108
+ cancelledAt: string | null;
109
+ createdAt: string | null;
110
+ features: Record<string, Feature>;
111
+ featuresOrder: string[];
112
+ usageCharges: UsageCharge[];
113
+ usageChargeCappedAmount?: number;
114
+ total: number;
115
+ subtotal: number;
116
+ presentmentTotal: number;
117
+ presentmentSubtotal: number;
118
+ /** Present on subscribe() responses — redirect the merchant's browser here. */
119
+ confirmationUrl?: string;
120
+ returnUrl?: string;
121
+ /** Mantlekit extra (not in Mantle's shape): raw status, e.g. "pending" | "active" | "cancelled". */
122
+ status: string;
123
+ }
124
+
125
+ export interface Customer {
62
126
  id: string;
63
- myshopifyDomain: string;
64
127
  name: string | null;
65
128
  email: string | null;
129
+ test: boolean;
130
+ platform: "shopify";
131
+ platformId?: string;
132
+ myshopifyDomain: string;
66
133
  installedAt: string | null;
67
- plan: { id: string; name: string } | null;
68
- subscription: { status: string; trialEndsAt: string | null; currentPeriodEnd: string | null };
69
- features: Record<string, ResolvedFeature>;
134
+ trialStartsAt: string | null;
135
+ trialExpiresAt: string | null;
136
+ /** The plans this shop is eligible for — same list as getPlans(). */
137
+ plans: Plan[];
138
+ subscription: Subscription | null;
139
+ paymentMethod: null;
140
+ features: Record<string, Feature>;
141
+ usage: Record<string, never>;
142
+ customFields: Record<string, unknown>;
143
+ usageCredits: never[];
144
+ reviews: never[];
145
+ billingStatus: "none" | "active" | "trialing" | "canceled" | "frozen";
70
146
  }
71
147
 
72
148
  export interface IdentifyParams {
@@ -92,16 +168,6 @@ export interface SubscribeParams {
92
168
  test?: boolean;
93
169
  }
94
170
 
95
- export interface SubscribeResult {
96
- subscriptionId: string;
97
- confirmationUrl: string;
98
- }
99
-
100
- export interface CancelSubscriptionResult {
101
- id: string;
102
- status: string;
103
- }
104
-
105
171
  export class MantleKitError extends Error {
106
172
  status: number;
107
173
  userErrors?: Array<{ field: string[] | null; message: string }>;
@@ -135,6 +201,21 @@ export interface MantleKitClientOptions {
135
201
  const env = (key: string): string | undefined =>
136
202
  typeof process !== "undefined" ? process.env?.[key] : undefined;
137
203
 
204
+ /**
205
+ * Mirrors MantleClient's private evaluateFeature(): booleans by value,
206
+ * limits by `count < value` with -1 meaning unlimited, overage limits
207
+ * always enabled (they never block, they bill).
208
+ */
209
+ function evaluateFeature(feature: Feature | undefined, count = 0): boolean {
210
+ if (!feature) return false;
211
+ if (feature.type === "limit") {
212
+ const limit = Number(feature.value);
213
+ return limit === -1 || count < limit;
214
+ }
215
+ if (feature.type === "limit_with_overage") return true;
216
+ return Boolean(feature.value);
217
+ }
218
+
138
219
  export class MantleKitClient {
139
220
  private baseUrl: string;
140
221
  private apiKey?: string;
@@ -159,36 +240,50 @@ export class MantleKitClient {
159
240
  }
160
241
 
161
242
  /** Plans this shop is eligible for — what your pricing page renders. Requires customerApiToken. */
162
- getPlans(): Promise<ResolvedPlan[]> {
163
- return this.request<ResolvedPlan[]>("GET", "/sdk/v1/plans", this.customerHeaders());
243
+ getPlans(): Promise<Plan[]> {
244
+ return this.request<Plan[]>("GET", "/sdk/v1/plans", this.customerHeaders());
164
245
  }
165
246
 
166
- /** The shop's own record: { plan, subscription, features }. Requires customerApiToken. */
167
- getCustomer(): Promise<ShopCustomer> {
168
- return this.request<ShopCustomer>("GET", "/sdk/v1/customer", this.customerHeaders());
247
+ /** The shop's own record — Mantle Customer shape: { plans, subscription: { plan, ... }, features, billingStatus }. */
248
+ getCustomer(): Promise<Customer> {
249
+ return this.request<Customer>("GET", "/sdk/v1/customer", this.customerHeaders());
169
250
  }
170
251
 
171
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
172
- async isFeatureEnabled(featureKey: string): Promise<boolean> {
252
+ /**
253
+ * Mirrors MantleClient.isFeatureEnabled({ featureKey, count }) limits
254
+ * evaluate as `count < limit` (-1 = unlimited). A bare string argument is
255
+ * also accepted: isFeatureEnabled("unlimited_bars").
256
+ */
257
+ async isFeatureEnabled(params: string | { featureKey: string; count?: number }): Promise<boolean> {
258
+ const { featureKey, count = 0 } = typeof params === "string" ? { featureKey: params, count: 0 } : params;
173
259
  const customer = await this.getCustomer();
174
- return Boolean(customer.features[featureKey]?.value);
260
+ return evaluateFeature(customer.features[featureKey], count);
175
261
  }
176
262
 
177
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
178
- async limitForFeature(featureKey: string): Promise<number | null> {
263
+ /**
264
+ * Mirrors MantleClient.limitForFeature({ featureKey }) resolves to -1
265
+ * when the feature is missing or not a limit type, exactly like Mantle.
266
+ */
267
+ async limitForFeature(params: string | { featureKey: string }): Promise<number> {
268
+ const featureKey = typeof params === "string" ? params : params.featureKey;
179
269
  const customer = await this.getCustomer();
180
- const value = customer.features[featureKey]?.value;
181
- return typeof value === "number" ? value : null;
270
+ const feature = customer.features[featureKey];
271
+ if (!feature || (feature.type !== "limit" && feature.type !== "limit_with_overage")) return -1;
272
+ return Number(feature.value);
182
273
  }
183
274
 
184
- /** Start checkout for a plan. Returns a confirmationUrl to redirect the merchant's browser to. Requires customerApiToken. */
185
- subscribe(params: SubscribeParams): Promise<SubscribeResult> {
186
- return this.request<SubscribeResult>("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
275
+ /**
276
+ * Start checkout for a plan. Resolves to the pending Subscription —
277
+ * redirect the merchant's browser to `subscription.confirmationUrl`.
278
+ * Requires customerApiToken.
279
+ */
280
+ subscribe(params: SubscribeParams): Promise<Subscription> {
281
+ return this.request<Subscription>("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
187
282
  }
188
283
 
189
- /** Cancel the shop's active subscription. Requires customerApiToken. */
190
- cancelSubscription(): Promise<CancelSubscriptionResult> {
191
- return this.request<CancelSubscriptionResult>("POST", "/sdk/v1/cancel-subscription", this.customerHeaders());
284
+ /** Cancel the shop's active subscription. Resolves to the cancelled Subscription. Requires customerApiToken. */
285
+ cancelSubscription(params?: { cancelReason?: string }): Promise<Subscription> {
286
+ return this.request<Subscription>("POST", "/sdk/v1/cancel-subscription", this.customerHeaders(), params ?? {});
192
287
  }
193
288
 
194
289
  private customerHeaders(): Record<string, string> {