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 +44 -22
- package/dist/client.d.ts +124 -47
- package/dist/client.js +55 -21
- package/package.json +2 -2
- package/src/client.ts +144 -49
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @sense-apps/mantle
|
|
2
2
|
|
|
3
|
-
Backend-only client your Shopify app uses to talk to your Mantlekit deployment.
|
|
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 @
|
|
11
|
-
# or: npm install @
|
|
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 "@
|
|
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.
|
|
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
|
-
//
|
|
74
|
-
// currencyCode,
|
|
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
|
|
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
|
-
###
|
|
95
|
+
### 5. Subscribe / upgrade
|
|
80
96
|
|
|
81
97
|
```ts
|
|
82
|
-
const
|
|
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
|
|
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
|
|
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
|
-
###
|
|
108
|
+
### 6. Gate features
|
|
92
109
|
|
|
93
110
|
```ts
|
|
94
|
-
|
|
95
|
-
// { id, myshopifyDomain, plan, subscription: { status, trialEndsAt, ... }, features }
|
|
111
|
+
if (await client.isFeatureEnabled({ featureKey: "unlimited_bars" })) { ... }
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
const
|
|
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
|
-
|
|
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
|
-
###
|
|
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 "@
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
38
|
-
* const
|
|
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
|
-
|
|
42
|
-
export
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
features: Record<string,
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
subscription:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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<
|
|
140
|
-
/** The shop's own record: {
|
|
141
|
-
getCustomer(): Promise<
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
38
|
-
* const
|
|
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: {
|
|
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
|
-
/**
|
|
80
|
-
|
|
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
|
|
106
|
+
return evaluateFeature(customer.features[featureKey], count);
|
|
83
107
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
/**
|
|
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.
|
|
4
|
-
"description": "Backend-only client for a self-hosted Mantlekit deployment —
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
38
|
-
* const
|
|
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
|
-
|
|
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
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
features: Record<string,
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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<
|
|
163
|
-
return this.request<
|
|
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: {
|
|
167
|
-
getCustomer(): Promise<
|
|
168
|
-
return this.request<
|
|
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
|
-
/**
|
|
172
|
-
|
|
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
|
|
260
|
+
return evaluateFeature(customer.features[featureKey], count);
|
|
175
261
|
}
|
|
176
262
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
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<
|
|
191
|
-
return this.request<
|
|
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> {
|