@sense-apps/mantle 0.1.0 → 0.3.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,84 @@ 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, discounts, autoAppliedDiscount, 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
+ **Discounts** work like Mantle's: created in the Mantlekit dashboard as a percentage (`percentage: 20` = 20%) or fixed amount off, optionally limited to specific plans, for a number of billing intervals (or forever / until a date), and auto-applied to shops by tag. When one auto-applies, `plan.autoAppliedDiscount` is set and `plan.total` is already the discounted price, with `plan.subtotal` the original — render a slashed price:
80
96
 
81
97
  ```ts
82
- const { confirmationUrl } = await client.subscribe({
98
+ if (plan.autoAppliedDiscount) {
99
+ render(`~~$${plan.subtotal}~~ $${plan.total}/mo — ${plan.autoAppliedDiscount.percentage}% off`);
100
+ }
101
+ ```
102
+
103
+ The same discount is forwarded to Shopify at subscribe time (on the recurring line item), so the approval screen and the real charge match what you displayed. Flex-billing plans never get auto-applied discounts (Shopify's discount input can't reach usage lines).
104
+
105
+ ### 5. Subscribe / upgrade
106
+
107
+ ```ts
108
+ const subscription = await client.subscribe({
83
109
  planId,
84
110
  returnUrl: `https://your-app.com/billing/confirm?shop=${shop.domain}`,
85
111
  });
86
- // redirect the merchant's browser to confirmationUrl (Shopify approval screen)
112
+ // redirect the merchant's browser to Shopify's approval screen:
113
+ redirect(subscription.confirmationUrl!);
87
114
  ```
88
115
 
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()`.
116
+ The subscription only becomes active after the merchant approves and Shopify confirms — don't grant features on redirect alone; check `getCustomer()`.
90
117
 
91
- ### 5. Gate features
118
+ ### 6. Gate features
92
119
 
93
120
  ```ts
94
- const customer = await client.getCustomer();
95
- // { id, myshopifyDomain, plan, subscription: { status, trialEndsAt, ... }, features }
121
+ if (await client.isFeatureEnabled({ featureKey: "unlimited_bars" })) { ... }
122
+
123
+ // limit features evaluate as `count < limit`, -1 = unlimited — Mantle's semantics:
124
+ const canAddBar = await client.isFeatureEnabled({ featureKey: "bars", count: currentBarCount });
96
125
 
97
- if (await client.isFeatureEnabled("advanced_reports")) { ... }
98
- const maxSeats = await client.limitForFeature("team_seats"); // number | null
126
+ const maxSeats = await client.limitForFeature({ featureKey: "team_seats" }); // -1 if not a limit feature
99
127
  ```
100
128
 
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.
129
+ 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
130
 
103
- ### 6. Cancel
131
+ ### 7. Cancel
104
132
 
105
133
  ```ts
106
- await client.cancelSubscription();
134
+ const cancelled = await client.cancelSubscription(); // resolves to the cancelled Subscription
107
135
  ```
108
136
 
137
+ ## What's intentionally empty
138
+
139
+ 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.
140
+
109
141
  ## Errors
110
142
 
111
143
  Every non-2xx response throws `MantleKitError` with `status` and, for validation failures, `userErrors: { field, message }[]`:
112
144
 
113
145
  ```ts
114
- import { MantleKitError } from "@mantlekit/sdk";
146
+ import { MantleKitError } from "@sense-apps/mantle";
115
147
 
116
148
  try {
117
149
  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,130 @@
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 Discount {
63
+ id: string;
64
+ name: string;
65
+ description: string | null;
66
+ /** Set for fixed-amount discounts. */
67
+ amount?: number;
68
+ amountCurrencyCode?: string;
69
+ presentmentAmount?: number;
70
+ presentmentCurrencyCode?: string;
71
+ /** Set for percentage discounts — human number, 20 = 20%. */
72
+ percentage?: number;
73
+ /** How many billing intervals the discount lasts; absent = forever. */
74
+ durationLimitInIntervals?: number;
75
+ /** The plan price after this discount. */
76
+ discountedAmount: number;
77
+ presentmentDiscountedAmount: number;
78
+ }
79
+ export interface Plan {
80
+ id: string;
50
81
  name: string;
51
- amount: string;
82
+ description: string | null;
83
+ availability: "public" | "customer" | "hidden";
84
+ type: "base";
52
85
  currencyCode: string;
86
+ presentmentAmount: number;
87
+ presentmentCurrencyCode: string;
88
+ /** Amount after discounts. Mantlekit has no discounts yet, so total === subtotal. */
89
+ total: number;
90
+ subtotal: number;
91
+ /** @deprecated Mantle deprecates this in favor of `subtotal`; kept for drop-in compat. */
92
+ amount: number;
93
+ public: boolean;
94
+ visible: boolean;
95
+ eligible: boolean;
53
96
  trialDays: number;
54
- viaOverride: boolean;
55
- features: Record<string, ResolvedFeature>;
97
+ interval: "EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY";
98
+ features: Record<string, Feature>;
99
+ featuresOrder: string[];
100
+ usageCharges: UsageCharge[];
101
+ usageChargeCappedAmount?: number;
102
+ customFields: Record<string, unknown>;
103
+ /** Discounts attached to this plan (auto-apply and manual alike). */
104
+ discounts: Discount[];
105
+ /**
106
+ * The discount that auto-applies to this shop at subscribe time (targeted
107
+ * by plan/tags in the Mantlekit dashboard). `total` already reflects it;
108
+ * `subtotal` is the undiscounted price — render a slashed price when set.
109
+ */
110
+ autoAppliedDiscount?: Discount;
111
+ flexBilling: boolean;
112
+ flexBillingTerms: string | null;
113
+ autoUpgradeToPlanId: string | null;
114
+ createdAt?: string;
56
115
  }
57
- export interface ShopCustomer {
116
+ export interface Subscription {
117
+ id: string;
118
+ plan: Plan;
119
+ lineItems: never[];
120
+ active: boolean;
121
+ activatedAt: string | null;
122
+ billingCycleAnchor: string | null;
123
+ currentPeriodStart: string | null;
124
+ currentPeriodEnd: string | null;
125
+ trialStartsAt: string | null;
126
+ trialExpiresAt: string | null;
127
+ cancelledAt: string | null;
128
+ createdAt: string | null;
129
+ features: Record<string, Feature>;
130
+ featuresOrder: string[];
131
+ usageCharges: UsageCharge[];
132
+ usageChargeCappedAmount?: number;
133
+ total: number;
134
+ subtotal: number;
135
+ presentmentTotal: number;
136
+ presentmentSubtotal: number;
137
+ /** Present on subscribe() responses — redirect the merchant's browser here. */
138
+ confirmationUrl?: string;
139
+ returnUrl?: string;
140
+ /** Mantlekit extra (not in Mantle's shape): raw status, e.g. "pending" | "active" | "cancelled". */
141
+ status: string;
142
+ }
143
+ export interface Customer {
58
144
  id: string;
59
- myshopifyDomain: string;
60
145
  name: string | null;
61
146
  email: string | null;
147
+ test: boolean;
148
+ platform: "shopify";
149
+ platformId?: string;
150
+ myshopifyDomain: string;
62
151
  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>;
152
+ trialStartsAt: string | null;
153
+ trialExpiresAt: string | null;
154
+ /** The plans this shop is eligible for — same list as getPlans(). */
155
+ plans: Plan[];
156
+ subscription: Subscription | null;
157
+ paymentMethod: null;
158
+ features: Record<string, Feature>;
159
+ usage: Record<string, never>;
160
+ customFields: Record<string, unknown>;
161
+ usageCredits: never[];
162
+ reviews: never[];
163
+ billingStatus: "none" | "active" | "trialing" | "canceled" | "frozen";
73
164
  }
74
165
  export interface IdentifyParams {
75
166
  myshopifyDomain: string;
@@ -91,14 +182,6 @@ export interface SubscribeParams {
91
182
  returnUrl: string;
92
183
  test?: boolean;
93
184
  }
94
- export interface SubscribeResult {
95
- subscriptionId: string;
96
- confirmationUrl: string;
97
- }
98
- export interface CancelSubscriptionResult {
99
- id: string;
100
- status: string;
101
- }
102
185
  export declare class MantleKitError extends Error {
103
186
  status: number;
104
187
  userErrors?: Array<{
@@ -136,17 +219,35 @@ export declare class MantleKitClient {
136
219
  /** Register (or re-sync) a shop after your app's own Shopify OAuth completes. Requires apiKey. */
137
220
  identify(params: IdentifyParams): Promise<IdentifyResult>;
138
221
  /** 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>;
222
+ getPlans(): Promise<Plan[]>;
223
+ /** The shop's own record — Mantle Customer shape: { plans, subscription: { plan, ... }, features, billingStatus }. */
224
+ getCustomer(): Promise<Customer>;
225
+ /**
226
+ * Mirrors MantleClient.isFeatureEnabled({ featureKey, count }) — limits
227
+ * evaluate as `count < limit` (-1 = unlimited). A bare string argument is
228
+ * also accepted: isFeatureEnabled("unlimited_bars").
229
+ */
230
+ isFeatureEnabled(params: string | {
231
+ featureKey: string;
232
+ count?: number;
233
+ }): Promise<boolean>;
234
+ /**
235
+ * Mirrors MantleClient.limitForFeature({ featureKey }) — resolves to -1
236
+ * when the feature is missing or not a limit type, exactly like Mantle.
237
+ */
238
+ limitForFeature(params: string | {
239
+ featureKey: string;
240
+ }): Promise<number>;
241
+ /**
242
+ * Start checkout for a plan. Resolves to the pending Subscription —
243
+ * redirect the merchant's browser to `subscription.confirmationUrl`.
244
+ * Requires customerApiToken.
245
+ */
246
+ subscribe(params: SubscribeParams): Promise<Subscription>;
247
+ /** Cancel the shop's active subscription. Resolves to the cancelled Subscription. Requires customerApiToken. */
248
+ cancelSubscription(params?: {
249
+ cancelReason?: string;
250
+ }): Promise<Subscription>;
150
251
  private customerHeaders;
151
252
  private request;
152
253
  }
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.3.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,137 @@
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 Discount {
67
+ id: string;
53
68
  name: string;
54
- amount: string;
69
+ description: string | null;
70
+ /** Set for fixed-amount discounts. */
71
+ amount?: number;
72
+ amountCurrencyCode?: string;
73
+ presentmentAmount?: number;
74
+ presentmentCurrencyCode?: string;
75
+ /** Set for percentage discounts — human number, 20 = 20%. */
76
+ percentage?: number;
77
+ /** How many billing intervals the discount lasts; absent = forever. */
78
+ durationLimitInIntervals?: number;
79
+ /** The plan price after this discount. */
80
+ discountedAmount: number;
81
+ presentmentDiscountedAmount: number;
82
+ }
83
+
84
+ export interface Plan {
85
+ id: string;
86
+ name: string;
87
+ description: string | null;
88
+ availability: "public" | "customer" | "hidden";
89
+ type: "base";
55
90
  currencyCode: string;
91
+ presentmentAmount: number;
92
+ presentmentCurrencyCode: string;
93
+ /** Amount after discounts. Mantlekit has no discounts yet, so total === subtotal. */
94
+ total: number;
95
+ subtotal: number;
96
+ /** @deprecated Mantle deprecates this in favor of `subtotal`; kept for drop-in compat. */
97
+ amount: number;
98
+ public: boolean;
99
+ visible: boolean;
100
+ eligible: boolean;
56
101
  trialDays: number;
57
- viaOverride: boolean;
58
- features: Record<string, ResolvedFeature>;
102
+ interval: "EVERY_30_DAYS" | "ANNUAL" | "QUARTERLY";
103
+ features: Record<string, Feature>;
104
+ featuresOrder: string[];
105
+ usageCharges: UsageCharge[];
106
+ usageChargeCappedAmount?: number;
107
+ customFields: Record<string, unknown>;
108
+ /** Discounts attached to this plan (auto-apply and manual alike). */
109
+ discounts: Discount[];
110
+ /**
111
+ * The discount that auto-applies to this shop at subscribe time (targeted
112
+ * by plan/tags in the Mantlekit dashboard). `total` already reflects it;
113
+ * `subtotal` is the undiscounted price — render a slashed price when set.
114
+ */
115
+ autoAppliedDiscount?: Discount;
116
+ flexBilling: boolean;
117
+ flexBillingTerms: string | null;
118
+ autoUpgradeToPlanId: string | null;
119
+ createdAt?: string;
59
120
  }
60
121
 
61
- export interface ShopCustomer {
122
+ export interface Subscription {
123
+ id: string;
124
+ plan: Plan;
125
+ lineItems: never[];
126
+ active: boolean;
127
+ activatedAt: string | null;
128
+ billingCycleAnchor: string | null;
129
+ currentPeriodStart: string | null;
130
+ currentPeriodEnd: string | null;
131
+ trialStartsAt: string | null;
132
+ trialExpiresAt: string | null;
133
+ cancelledAt: string | null;
134
+ createdAt: string | null;
135
+ features: Record<string, Feature>;
136
+ featuresOrder: string[];
137
+ usageCharges: UsageCharge[];
138
+ usageChargeCappedAmount?: number;
139
+ total: number;
140
+ subtotal: number;
141
+ presentmentTotal: number;
142
+ presentmentSubtotal: number;
143
+ /** Present on subscribe() responses — redirect the merchant's browser here. */
144
+ confirmationUrl?: string;
145
+ returnUrl?: string;
146
+ /** Mantlekit extra (not in Mantle's shape): raw status, e.g. "pending" | "active" | "cancelled". */
147
+ status: string;
148
+ }
149
+
150
+ export interface Customer {
62
151
  id: string;
63
- myshopifyDomain: string;
64
152
  name: string | null;
65
153
  email: string | null;
154
+ test: boolean;
155
+ platform: "shopify";
156
+ platformId?: string;
157
+ myshopifyDomain: string;
66
158
  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>;
159
+ trialStartsAt: string | null;
160
+ trialExpiresAt: string | null;
161
+ /** The plans this shop is eligible for — same list as getPlans(). */
162
+ plans: Plan[];
163
+ subscription: Subscription | null;
164
+ paymentMethod: null;
165
+ features: Record<string, Feature>;
166
+ usage: Record<string, never>;
167
+ customFields: Record<string, unknown>;
168
+ usageCredits: never[];
169
+ reviews: never[];
170
+ billingStatus: "none" | "active" | "trialing" | "canceled" | "frozen";
70
171
  }
71
172
 
72
173
  export interface IdentifyParams {
@@ -92,16 +193,6 @@ export interface SubscribeParams {
92
193
  test?: boolean;
93
194
  }
94
195
 
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
196
  export class MantleKitError extends Error {
106
197
  status: number;
107
198
  userErrors?: Array<{ field: string[] | null; message: string }>;
@@ -135,6 +226,21 @@ export interface MantleKitClientOptions {
135
226
  const env = (key: string): string | undefined =>
136
227
  typeof process !== "undefined" ? process.env?.[key] : undefined;
137
228
 
229
+ /**
230
+ * Mirrors MantleClient's private evaluateFeature(): booleans by value,
231
+ * limits by `count < value` with -1 meaning unlimited, overage limits
232
+ * always enabled (they never block, they bill).
233
+ */
234
+ function evaluateFeature(feature: Feature | undefined, count = 0): boolean {
235
+ if (!feature) return false;
236
+ if (feature.type === "limit") {
237
+ const limit = Number(feature.value);
238
+ return limit === -1 || count < limit;
239
+ }
240
+ if (feature.type === "limit_with_overage") return true;
241
+ return Boolean(feature.value);
242
+ }
243
+
138
244
  export class MantleKitClient {
139
245
  private baseUrl: string;
140
246
  private apiKey?: string;
@@ -159,36 +265,50 @@ export class MantleKitClient {
159
265
  }
160
266
 
161
267
  /** 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());
268
+ getPlans(): Promise<Plan[]> {
269
+ return this.request<Plan[]>("GET", "/sdk/v1/plans", this.customerHeaders());
164
270
  }
165
271
 
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());
272
+ /** The shop's own record — Mantle Customer shape: { plans, subscription: { plan, ... }, features, billingStatus }. */
273
+ getCustomer(): Promise<Customer> {
274
+ return this.request<Customer>("GET", "/sdk/v1/customer", this.customerHeaders());
169
275
  }
170
276
 
171
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.isFeatureEnabled(). */
172
- async isFeatureEnabled(featureKey: string): Promise<boolean> {
277
+ /**
278
+ * Mirrors MantleClient.isFeatureEnabled({ featureKey, count }) limits
279
+ * evaluate as `count < limit` (-1 = unlimited). A bare string argument is
280
+ * also accepted: isFeatureEnabled("unlimited_bars").
281
+ */
282
+ async isFeatureEnabled(params: string | { featureKey: string; count?: number }): Promise<boolean> {
283
+ const { featureKey, count = 0 } = typeof params === "string" ? { featureKey: params, count: 0 } : params;
173
284
  const customer = await this.getCustomer();
174
- return Boolean(customer.features[featureKey]?.value);
285
+ return evaluateFeature(customer.features[featureKey], count);
175
286
  }
176
287
 
177
- /** Convenience wrapper over getCustomer().features — mirrors MantleClient.limitForFeature(). */
178
- async limitForFeature(featureKey: string): Promise<number | null> {
288
+ /**
289
+ * Mirrors MantleClient.limitForFeature({ featureKey }) resolves to -1
290
+ * when the feature is missing or not a limit type, exactly like Mantle.
291
+ */
292
+ async limitForFeature(params: string | { featureKey: string }): Promise<number> {
293
+ const featureKey = typeof params === "string" ? params : params.featureKey;
179
294
  const customer = await this.getCustomer();
180
- const value = customer.features[featureKey]?.value;
181
- return typeof value === "number" ? value : null;
295
+ const feature = customer.features[featureKey];
296
+ if (!feature || (feature.type !== "limit" && feature.type !== "limit_with_overage")) return -1;
297
+ return Number(feature.value);
182
298
  }
183
299
 
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);
300
+ /**
301
+ * Start checkout for a plan. Resolves to the pending Subscription —
302
+ * redirect the merchant's browser to `subscription.confirmationUrl`.
303
+ * Requires customerApiToken.
304
+ */
305
+ subscribe(params: SubscribeParams): Promise<Subscription> {
306
+ return this.request<Subscription>("POST", "/sdk/v1/subscribe", this.customerHeaders(), params);
187
307
  }
188
308
 
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());
309
+ /** Cancel the shop's active subscription. Resolves to the cancelled Subscription. Requires customerApiToken. */
310
+ cancelSubscription(params?: { cancelReason?: string }): Promise<Subscription> {
311
+ return this.request<Subscription>("POST", "/sdk/v1/cancel-subscription", this.customerHeaders(), params ?? {});
192
312
  }
193
313
 
194
314
  private customerHeaders(): Record<string, string> {