@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 +54 -22
- package/dist/client.d.ts +148 -47
- package/dist/client.js +55 -21
- package/package.json +2 -2
- package/src/client.ts +169 -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,84 @@ 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, discounts, autoAppliedDiscount, 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
|
+
**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
|
-
|
|
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
|
|
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
|
|
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
|
-
###
|
|
118
|
+
### 6. Gate features
|
|
92
119
|
|
|
93
120
|
```ts
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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 "@
|
|
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
|
-
* @
|
|
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,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
|
|
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 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
|
-
|
|
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
|
-
|
|
55
|
-
features: Record<string,
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
subscription:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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<
|
|
140
|
-
/** The shop's own record: {
|
|
141
|
-
getCustomer(): Promise<
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
* @
|
|
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.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
|
-
* @
|
|
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,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
|
|
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 Discount {
|
|
67
|
+
id: string;
|
|
53
68
|
name: string;
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
features: Record<string,
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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<
|
|
163
|
-
return this.request<
|
|
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: {
|
|
167
|
-
getCustomer(): Promise<
|
|
168
|
-
return this.request<
|
|
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
|
-
/**
|
|
172
|
-
|
|
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
|
|
285
|
+
return evaluateFeature(customer.features[featureKey], count);
|
|
175
286
|
}
|
|
176
287
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
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<
|
|
191
|
-
return this.request<
|
|
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> {
|