├── .github
└── workflows
│ ├── ci.yml
│ ├── commit-if-modified.sh
│ ├── copyright-year.sh
│ ├── package-json-repo.js
│ └── typedoc.yml
├── .gitignore
├── .naverc
├── .prettierignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── cli-packages
├── .gitignore
├── SHASUMS.txt
├── create.sh
└── publish.sh
├── fixup.sh
├── package-lock.json
├── package.json
├── scripts
└── reserve-stress-test.ts
├── src
├── answer.ts
├── backoff.ts
├── client.ts
├── get-client.ts
├── index.ts
├── is.ts
├── tier-error.ts
├── tier-types.ts
└── version.ts
├── tap-snapshots
└── test
│ └── is.ts.test.cjs
├── test
├── client.ts
├── fetch-no-polyfill.ts
├── fetch-polyfill.ts
├── index.ts
├── init.ts
└── is.ts
├── tsconfig-base.json
├── tsconfig-cjs.json
├── tsconfig-esm.json
└── typedoc.json
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | strategy:
8 | matrix:
9 | node-version: [16.x, 18.x, 19.x]
10 | platform:
11 | - os: ubuntu-latest
12 | shell: bash
13 | - os: macos-latest
14 | shell: bash
15 | - os: windows-latest
16 | shell: powershell
17 | fail-fast: false
18 |
19 | runs-on: ${{ matrix.platform.os }}
20 | defaults:
21 | run:
22 | shell: ${{ matrix.platform.shell }}
23 |
24 | steps:
25 | - name: Checkout Repository
26 | uses: actions/checkout@v3
27 |
28 | - name: Use Nodejs ${{ matrix.node-version }}
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: ${{ matrix.node-version }}
32 |
33 | - name: Install dependencies
34 | run: npm install
35 |
36 | - name: Run Tests
37 | run: npm test -- -c -t0
38 |
--------------------------------------------------------------------------------
/.github/workflows/commit-if-modified.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | git config --global user.email "$1"
3 | shift
4 | git config --global user.name "$1"
5 | shift
6 | message="$1"
7 | shift
8 | if [ $(git status --porcelain "$@" | egrep '^ M' | wc -l) -gt 0 ]; then
9 | git add "$@"
10 | git commit -m "$message"
11 | git push || git pull --rebase
12 | git push
13 | fi
14 |
--------------------------------------------------------------------------------
/.github/workflows/copyright-year.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | dir=${1:-$PWD}
3 | dates=($(git log --date=format:%Y --pretty=format:'%ad' --reverse | sort | uniq))
4 | if [ "${#dates[@]}" -eq 1 ]; then
5 | datestr="${dates}"
6 | else
7 | datestr="${dates}-${dates[${#dates[@]}-1]}"
8 | fi
9 |
10 | stripDate='s/^((.*)Copyright\b(.*?))((?:,\s*)?(([0-9]{4}\s*-\s*[0-9]{4})|(([0-9]{4},\s*)*[0-9]{4})))(?:,)?\s*(.*)\n$/$1$9\n/g'
11 | addDate='s/^.*Copyright(?:\s*\(c\))? /Copyright \(c\) '$datestr' /g'
12 | for l in $dir/LICENSE*; do
13 | perl -pi -e "$stripDate" $l
14 | perl -pi -e "$addDate" $l
15 | done
16 |
--------------------------------------------------------------------------------
/.github/workflows/package-json-repo.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const pf = require.resolve(`${process.cwd()}/package.json`)
4 | const pj = require(pf)
5 |
6 | if (!pj.repository && process.env.GITHUB_REPOSITORY) {
7 | const fs = require('fs')
8 | const server = process.env.GITHUB_SERVER_URL || 'https://github.com'
9 | const repo = `${server}/${process.env.GITHUB_REPOSITORY}`
10 | pj.repository = repo
11 | const json = fs.readFileSync(pf, 'utf8')
12 | const match = json.match(/^\s*\{[\r\n]+([ \t]*)"/)
13 | const indent = match[1]
14 | const output = JSON.stringify(pj, null, indent || 2) + '\n'
15 | fs.writeFileSync(pf, output)
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/typedoc.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: "pages"
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 | - name: Use Nodejs ${{ matrix.node-version }}
34 | uses: actions/setup-node@v3
35 | with:
36 | node-version: 18.x
37 | - name: Install dependencies
38 | run: npm install
39 | - name: Generate typedocs
40 | run: npm run typedoc
41 |
42 | - name: Setup Pages
43 | uses: actions/configure-pages@v3
44 | - name: Upload artifact
45 | uses: actions/upload-pages-artifact@v1
46 | with:
47 | path: './docs'
48 | - name: Deploy to GitHub Pages
49 | id: deployment
50 | uses: actions/deploy-pages@v1
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # ignore most things, include some others
2 | /*
3 | /.*
4 | /lib
5 | /dist
6 | !/.naverc
7 | !/tsconfig*.json
8 | !/fixup.sh
9 | !/cli-packages
10 | !/bin
11 | !/src
12 | /docs
13 | !/package.json
14 | !/package-lock.json
15 | !/README.md
16 | !/CONTRIBUTING.md
17 | !/LICENSE
18 | !/CHANGELOG.md
19 | !/example
20 | !/scripts
21 | !/tap-snapshots
22 | !/test
23 | !/.github
24 | !/.gitignore
25 | !/.gitattributes
26 | !/.prettierignore
27 | !/coverage-map.js
28 | !/map.js
29 | !/index.js
30 | !/typedoc.json
31 |
--------------------------------------------------------------------------------
/.naverc:
--------------------------------------------------------------------------------
1 | tier-node-sdk 18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /.github
3 | /lib
4 | .env
5 | /tap-snapshots
6 | /cli-packages
7 | /.nyc_output
8 | /coverage
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # change log
2 |
3 | ## 5.9
4 |
5 | - Support `userAgent: false` option to suppress adding
6 | `user-agent` header
7 |
8 | ## 5.8
9 |
10 | - Include `user-agent` header, can be set with `userAgent` option
11 |
12 | ## 5.7
13 |
14 | - Add current.effective/end to CurrentPhase
15 |
16 | ## 5.6
17 |
18 | - default baseURL to https://api.tier.run if apiKey is set
19 |
20 | ## 5.5
21 |
22 | - add end, trial to CurrentPhase
23 | - Add test clock support
24 |
25 | ## 5.4
26 |
27 | - add support for FeatureDefinition.divide
28 |
29 | ## 5.3
30 |
31 | - Add support for passing in an AbortSignal to cancel any
32 | requests on a given Tier client.
33 |
34 | ## 5.2
35 |
36 | - Add paymentMethodID, invoiceSettings, lookupPaymentMethods,
37 | requireBillingAddress
38 |
39 | ## 5.1
40 |
41 | - Support added for making requests to the Tier API server using
42 | CORS and browser credentials.
43 | - Added `onError` option to Tier client.
44 |
45 | ## 5.0
46 |
47 | - Add support for external Tier API server with Tier API Key
48 | - Rename `TIER_SIDECAR` env to `TIER_BASE_URL`
49 | - Refactor `child_process` into a dynamic import so that the main
50 | export can be safely used where `child_process` is unavailable.
51 |
52 | ## 4.1
53 |
54 | - Remove previous experimental iteration of Checkout
55 | - Implement Checkout using `/v1/checkout` API endpoint
56 | - This requires version 0.7.1 or higher of the tier binary
57 |
58 | ## 4.0
59 |
60 | - Move growing parameter lists into `SubscribeOptions` and
61 | `ScheduleOptions` object params.
62 | - Add support for `trialDays` option to `tier.subscribe()`
63 | - Add `tier.cancel()` method
64 | - Add experimental support for `checkout` option to
65 | `tier.subscribe()` and `tier.schedule()`
66 | - Consistently name option argument types as `{Thing}Params`
67 | instead of `{Thing}Options`
68 | - Add `tier.can()` interface
69 |
70 | ## 3.0
71 |
72 | - Add `tier.push()` method
73 | - Separate raw client mode from managed sidecar mode, to support
74 | environments lacking Node's `child_process` module.
75 |
76 | ## 2.4
77 |
78 | - Improve error handling
79 |
80 | ## 2.3
81 |
82 | - `tier.pull()` method
83 | - `tier.pullLatest()` method
84 | - use named exports
85 |
86 | ## 2.2
87 |
88 | - `tier.limit()` method
89 |
90 | ## 2.1
91 |
92 | - `tier.phase()` method
93 | - start sidecar listening on PID-specific port
94 |
95 | ## 2.x
96 |
97 | Initial beta release.
98 |
99 | ## 1.x
100 |
101 | All 1.x versions are actually a different (abandoned) package.
102 | Special thanks to [Tian Jian](http://npm.im/~kiliwalk) for
103 | graciously letting us take over the name he was no longer using.
104 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2022 - 2023, Tier.run Inc
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tier Node SDK
2 |
3 | SDK for using https://tier.run in Node.js applications
4 |
5 | [Generated typedocs](https://tierrun.github.io/node-sdk/)
6 |
7 | ## INSTALLING
8 |
9 | First, install [the Tier binary](https://tier.run/docs/install).
10 |
11 | Run `tier connect` to authorize Tier to your Stripe account, or
12 | provide a `STRIPE_API_KEY` environment variable.
13 |
14 | ```bash
15 | npm install tier
16 | ```
17 |
18 | ## Overview
19 |
20 | This is the SDK that can may be used to integrate Tier into your
21 | application. More details on the general concepts used by Tier
22 | may be found at .
23 |
24 | The SDK works by talking to an instance of the Tier binary
25 | running as a sidecar, using `tier serve`.
26 |
27 | ## USAGE
28 |
29 | Note: support for using the Tier JavaScript client in web
30 | browsers is **EXPERIMENTAL**. Whatever you do, please don't put
31 | your private Stripe keys where web browsers can see them.
32 |
33 | This module exports both a zero-dependency client class, suitable
34 | for use in non-Node.js environments such as edge workers and
35 | Deno, as well as a simple functional SDK that manages spinning up
36 | the sidecar automatically.
37 |
38 | ### Automatic Mode, Remote API Service
39 |
40 | This works on any server-side contexts where you can set
41 | environment variables.
42 |
43 | Set the `TIER_BASE_URL` and `TIER_API_KEY` environment variables
44 | to the URL to the remote Tier service and the API key for the
45 | service.
46 |
47 | Import the main module, and use the methods provided.
48 |
49 | ```ts
50 | // Set process.env.TIER_BASE_URL and process.env.TIER_API_KEY
51 |
52 | // hybrid module, either form works
53 | import tier from 'tier'
54 | // or
55 | const { default: tier } = require('tier')
56 | // that's it, it'll talk to the API server you set
57 | ```
58 |
59 | ### Automatic Mode, Sidecar on Localhost
60 |
61 | This works if you have Tier installed locally.
62 |
63 | Don't set any environment variables, just import the main module,
64 | and use the API methods provided.
65 |
66 | A Tier API sidecar process will be started on the first API
67 | method call. It will listen on a port determined by the process
68 | ID, and automatically shut down when the process terminates.
69 |
70 | To operate on live Stripe data (that is, to start the sidecar in
71 | live mode), set `TIER_LIVE=1` in the environment.
72 |
73 | ```ts
74 | // hybrid module, either form works
75 | import tier from 'tier'
76 | // or
77 | const { default: tier } = require('tier')
78 | // that's it, it'll start the sidecar as needed
79 | ```
80 |
81 | Note that you must have previously run [`tier
82 | connect`](https://tier.run/docs/cli/connect) to authorize Tier to
83 | access your Stripe account, or set the `STRIPE_API_KEY`
84 | environment variable.
85 |
86 | This requires that Node's `child_process` module is available, so
87 | does not work with environments that do not have access to it. If
88 | `fetch` is not available, then the optional `node-fetch`
89 | dependency will be loaded as a polyfill.
90 |
91 | If you want a client instance that is automatically configured by
92 | the environment settings, with an on-demand started tier API
93 | sidecar, you can call:
94 |
95 | ```ts
96 | const client = await tier.fromEnv()
97 | ```
98 |
99 | ### Custom Client Custom Mode
100 |
101 | To use Tier in an environment where `child_process.spawn` is not
102 | available, or where you cannot set environment variables, you
103 | can load and instantiate the client yourself:
104 |
105 | ```ts
106 | // hybrid module, either works
107 | import { Tier } from 'tier/client'
108 | // or
109 | const { Tier } = require('tier/client')
110 | // or, if using deno or CFW and you don't have import maps:
111 | import { Tier } from 'https://unpkg.com/tier@^5/dist/mjs/client.js'
112 |
113 | const tier = new Tier({
114 | // Required: the base url to the running `tier serve` instance
115 | baseURL: tierAPIServiceURL,
116 |
117 | // optional, defaults to '', set an API key to access the service
118 | apiKey: tierAPIKey,
119 |
120 | // optional, if set will catch all API errors.
121 | // Note that this makes the promises from API calls resolve,
122 | // unless the onError function re-throws! Use with caution!
123 | //
124 | // onError: (er: TierError) => {
125 | // console.error(er)
126 | // throw er
127 | // }
128 |
129 | // Optional, only needed if fetch global is not available
130 | // fetchImpl: myFetchImplementation,
131 |
132 | // Optional, defaults to false, will make a lot of
133 | // console.error() calls.
134 | // debug: false
135 |
136 | // Optional, can be used to terminate all actions by this client
137 | // signal: myAbortSignal
138 | })
139 | ```
140 |
141 | Then call API methods from the tier instance.
142 |
143 | This is how you can use the Tier SDK from Cloudflare Workers,
144 | Deno, and other non-Node JavaScript environments.
145 |
146 | ### Error Handling
147 |
148 | All methods will raise a `TierError` object if there's a non-2xx
149 | response from the Tier sidecar, or if a response is not valid
150 | JSON.
151 |
152 | This Error subclass contains the following fields:
153 |
154 | - `status` - number, the HTTP status code received
155 | - `code` - Short string representation of the error, something like `not_found`
156 | - `message` - Human-readable explanation of the error.
157 | - `path` - The API path being accessed
158 | - `requestData` - The data sent to the API path (query string for
159 | GETs, request body for POSTs.)
160 | - `responseData` - The response data returned by the API
161 | endpoint.
162 |
163 | ## API METHODS
164 |
165 | ### `subscribe(org, plan, { info, trialDays, paymentMethodID })`
166 |
167 | Subscribe an org to the specified plan effective immediately.
168 |
169 | Plan may be either a versioned plan name (for example,
170 | `plan:bar@1`), or "feature plan" name (for example
171 | `feature:foo@plan:bar@1`), or an array of versioned plan names
172 | and feature plan names.
173 |
174 | If no effective date is provided, then the plan is effective
175 | immediately.
176 |
177 | If `info` is provided, it updates the org with info in the same
178 | way as calling `updateOrg(org, info)`.
179 |
180 | If `trialDays` is a number greater than 0, then a trial phase
181 | will be prepended with the same features, and the effective date
182 | will on the non-trial phase will be delayed until the end of the
183 | trial period.
184 |
185 | If a string `paymentMethodID` is specified, then it will be used
186 | as the billing method for the subscription.
187 |
188 | ### `schedule(org, phases, { info, paymentMethodID })`
189 |
190 | Create a subscription schedule phase for each of the sets of
191 | plans specified in the `phases` array.
192 |
193 | Each item in `phases` must be an object containing:
194 |
195 | - `features` Array of versioned plan names (for example,
196 | `plan:bar@1`), or "feature plan" names (for example,
197 | `feature:foo@plan:bar@1`)
198 | - `effective` Optional effective date.
199 | - `trial` Optional boolean indicating whether this is a trial or
200 | an actual billed phase, default `false`
201 |
202 | If no effective date is provided, then the phase takes effect
203 | immediately. Note that the first phase in the list MUST NOT
204 | have an effective date, and start immediately.
205 |
206 | If `info` is provided, it updates the org with info in the same
207 | way as calling `updateOrg(org, info)`.
208 |
209 | If a string `paymentMethodID` is specified, then it will be used
210 | as the billing method for the subscription.
211 |
212 | ### `checkout(org, successUrl, { cancelUrl, features, trialDays, requireBillingAddress, tax })`
213 |
214 | Generate a Stripe Checkout flow, and return a `{ url }` object.
215 | Redirect the user to that `url` to have them complete the
216 | checkout flow. Stripe will redirect them back to the
217 | `successUrl` when the flow is completed successfully.
218 |
219 | Optional parameters:
220 |
221 | - `cancelUrl` if provided, then the user will be redirected to
222 | the supplied url if they cancel the process.
223 | - `features` Either a versioned plan name (for example,
224 | `plan:bar@1`), or "feature plan" name (for example
225 | `feature:foo@plan:bar@1`), or an array of versioned plan names
226 | and feature plan names. If provided, then the user will be
227 | subscribed to the relevant plan(s) once they complete the
228 | Checkout flow. If not provided, then the Checkout flow will
229 | only gather customer information.
230 | - `trialDays` Number of days to put the user on a "trial plan",
231 | where they are not charged for any usage. Only allowed when
232 | `features` is provided.
233 | - `requireBillingAddress` If set to `true`, then the user will be
234 | required to add a billing address to complete the checkout
235 | flow.
236 | - `tax` Configure automatic tax collection
237 |
238 | ### `updateOrg(org, info)`
239 |
240 | Update the specified org with the supplied information.
241 |
242 | `info` is an object containing the following fields:
243 |
244 | - `email` string
245 | - `name` string
246 | - `description` string
247 | - `phone` string
248 | - `metadata` Object with any arbitrary keys and `string` values
249 | - `invoiceSettings` An object which may contain a
250 | `defaultPaymentMethod` string. If set, it will be attached as
251 | the org's default invoice payment method.
252 |
253 | Note that any string fields that are missing will result in that
254 | data being removed from the org's Customer record in Stripe, as
255 | if `''` was specified.
256 |
257 | ### `cancel(org)`
258 |
259 | Immediately cancels all current and pending subscriptions for the
260 | specified org.
261 |
262 | ### `lookupLimits(org)`
263 |
264 | Retrieve the usage data and limits for an org.
265 |
266 | ```json
267 | {
268 | "org": "org:user",
269 | "usage": [
270 | {
271 | "feature": "feature:storage",
272 | "used": 341,
273 | "limit": 10000
274 | },
275 | {
276 | "feature": "feature:transfer",
277 | "used": 234213,
278 | "limit": 10000
279 | }
280 | ]
281 | }
282 | ```
283 |
284 | ### `lookupLimit(org, feature)`
285 |
286 | Retrieve the usage and limit data for an org and single feature.
287 |
288 | ```json
289 | {
290 | "feature": "feature:storage",
291 | "used": 341,
292 | "limit": 10000
293 | }
294 | ```
295 |
296 | If the org does not have access to the feature, then an object is
297 | returned with `usage` and `limit` set to `0`.
298 |
299 | ```json
300 | {
301 | "feature": "feature:noaccess",
302 | "used": 0,
303 | "limit": 0
304 | }
305 | ```
306 |
307 | ### `report(org, feature, [n = 1], [options = {}])`
308 |
309 | Report usage of a feature by an org.
310 |
311 | The optional `n` parameter indicates the number of units of the
312 | feature that were consumed.
313 |
314 | Options object may contain the following fields:
315 |
316 | - `at` Date object indicating when the usage took place.
317 | - `clobber` boolean indicating that the usage amount should
318 | override any previously reported usage of the feature for the
319 | current subscription phase.
320 |
321 | ### `can(org, feature)`
322 |
323 | `can` is a convenience function for checking if an org has used
324 | more of a feature than they are entitled to and optionally
325 | reporting usage post check and consumption.
326 |
327 | If reporting consumption is not required, it can be used in the
328 | form:
329 |
330 | ```js
331 | const answer = await tier.can('org:acme', 'feature:convert')
332 | if (answer.ok) {
333 | //...
334 | }
335 | ```
336 |
337 | reporting usage post consumption looks like:
338 |
339 | ```js
340 | const answer = await tier.can('org:acme', 'feature:convert')
341 | if (!answer.ok) {
342 | return ''
343 | }
344 | answer.report().catch(er => {
345 | // error occurred reporting usage, log or handle it here
346 | })
347 |
348 | // but don't wait to deliver the feature
349 | return convert(temp)
350 | ```
351 |
352 | ### `whois(org)`
353 |
354 | Retrieve the Stripe Customer ID for an org.
355 |
356 | ```json
357 | {
358 | "org": "org:user",
359 | "stripe_id": "cus_v49o7xMpZaMbzg"
360 | }
361 | ```
362 |
363 | ### `lookupOrg(org)`
364 |
365 | Retrieve the full org info, with `stripe_id`, along with email,
366 | name, description, phone, metadata, and invoiceSettings.
367 |
368 | ### `lookupPaymentMethods(org)`
369 |
370 | Return a `PaymentMethodsResponse` object, containing the org name
371 | and an array of their available payment method IDs.
372 |
373 | ```json
374 | {
375 | "org": "org:acme",
376 | "methods": ["pm_card_3h39ehaiweheawfhiawhfasi"]
377 | }
378 | ```
379 |
380 | If the org does not have any payment methods, then the returned
381 | object will contain an empty array.
382 |
383 | ### `whoami()`
384 |
385 | Retrieve information about the current logged in Stripe account.
386 |
387 | ### `lookupPhase(org)`
388 |
389 | Retrieve the current schedule phase for the org. This provides a
390 | list of the features and plans that the org is currently
391 | subscribed to, which can be useful information when creating a
392 | user interface for upgrading/downgrading pricing plans.
393 |
394 | ```json
395 | {
396 | "effective": "2022-10-13T16:52:11-07:00",
397 | "features": ["feature:storage@plan:free@1", "feature:transfer@plan:free@1"],
398 | "plans": ["plan:free@1"]
399 | }
400 | ```
401 |
402 | Note: This should **not** be used for checking entitlements and
403 | feature gating. Instead, use the `Tier.lookupLimit()` method and check
404 | the limit and usage for the feature in question.
405 |
406 | For example:
407 |
408 | ```
409 | // Do not do this! You will regret it!
410 | const phase = await Tier.lookupPhase(`org:${customerID}`)
411 | if (phase.plans.some(plan => plan.startsWith('plan:pro')) {
412 | showSpecialFeature()
413 | }
414 | ```
415 |
416 | Instead, do this:
417 |
418 | ```js
419 | const usage = await Tier.lookupLimit(`org:${customerID}`, 'feature:special')
420 | if (usage.limit < usage.used) {
421 | showSpecialFeature()
422 | }
423 | ```
424 |
425 | ### `pull()`
426 |
427 | Fetches the pricing model from Stripe.
428 |
429 | ### `pullLatest()`
430 |
431 | **Experimental**
432 |
433 | Fetches the pricing model from Stripe, but only shows the plans
434 | with the highest versions (lexically sorted). This can be useful
435 | in building pricing pages in your application.
436 |
437 | Plan versions are sorted numerically if they are decimal
438 | integers, or lexically in the `en` locale otherwise.
439 |
440 | So, for example, the plan version `20test` will be considered
441 | "lower" than `9test`, because the non-numeric string causes it to
442 | be lexically sorted. But the plan version `20` sill be
443 | considered "higher" than the plan version `9`, because both are
444 | strictly numeric.
445 |
446 | For example, if `Tier.pull()` returns this:
447 |
448 | ```json
449 | {
450 | "plans": {
451 | "plan:mixednum@9test": {},
452 | "plan:mixednum@9999999": {},
453 | "plan:mixednum@0test": {},
454 | "plan:mixednum@1000": {},
455 | "plan:alpha@dog": {},
456 | "plan:alpha@cat": {},
457 | "plan:longnum@1000": {},
458 | "plan:longnum@99": {},
459 | "plan:foo@1": {},
460 | "plan:foo@0": {},
461 | "plan:bar@7": {},
462 | "plan:foo@2": {},
463 | "plan:bar@0": {}
464 | }
465 | }
466 | ```
467 |
468 | then `Tier.pullLatest()` will return:
469 |
470 | ```js
471 | {
472 | plans: {
473 | // these are all sorted numerically, because the versions
474 | // are simple positive integers without any leading 0
475 | // characters.
476 | 'plan:foo@2': {},
477 | 'plan:bar@7': {},
478 | 'plan:longnum@1000': {},
479 | // 'dog' and 'cat' sorted lexically, 'd' > 'c'
480 | 'plan:alpha@dog': {},
481 | // these are sorted lexically, because even though SOME of
482 | // are strictly numeric, this one is not.
483 | 'plan:mixednum@9test': {}
484 | }
485 | }
486 | ```
487 |
488 | ### `push(model)`
489 |
490 | Creates the `Product` and `Price` objects in Stripe corresponding
491 | to the supplied pricing Model (as would be found in a
492 | [`pricing.json` file](https://tier.run/docs/pricing.json)).
493 |
494 | Returns an object detailing which features were created, and
495 | which either had errors or already existed. Note that a
496 | successful response from this method does not mean that _all_ of
497 | the features were created (since, for example, some may already
498 | exist), only that _some_ of them were.
499 |
500 | ### `async withClock(name: string, present?: Date)`
501 |
502 | Create a test clock with the given name, and return a `Tier`
503 | client configured to use that clock.
504 |
505 | ### `async advance(present: Date)`
506 |
507 | Advance the clock to the specified date.
508 |
509 | Rejects if the client was not created by `tier.withClock()`.
510 |
511 | ### Class: `Answer`
512 |
513 | `Answer` is the type of object returned by `tier.can()`.
514 |
515 | #### `answer.ok`
516 |
517 | `ok` reports if the program should proceed with a user request or
518 | not. To prevent total failure if `can()` needed to reach the sidecar
519 | and was unable to, `ok` will fail optimistically and report true.
520 | If the opposite is desired, clients can check `err`.
521 |
522 | #### `answer.err`
523 |
524 | Any error encountered fetching the `Usage` record for the org and
525 | feature.
526 |
527 | #### `answer.report([n = 1])`
528 |
529 | Report the usage in the amount specified, default `1`.
530 |
531 | #### `answer.limit`
532 |
533 | Number specifying the limit for the feature usage.
534 |
535 | #### `answer.used`
536 |
537 | Number specifying the amount of the feature that the org has
538 | consumed.
539 |
540 | #### `answer.remaining`
541 |
542 | Number specifying the amount of feature consumption that is
543 | remaining.
544 |
545 | ### Class: `TierError`
546 |
547 | `TierError` is a subclass of `Error` which is raised whenever
548 | Tier encounters a problem fetching data.
549 |
550 | - `message`: the `message` field from the sidear, if present, or
551 | `"Tier request failed"`
552 | - `path`: the path on the sidecar API that was requested
553 | - `requestData`: the data that was sent to the sidecar
554 | - `status`: the HTTP response status code from the sidecar, if a
555 | response was returned
556 | - `code`: response error code returned by the sidecar, if present
557 | - `responseData`: the raw HTTP body sent by the sidecar
558 | - `cause`: If triggered by an underlying system or JSON.parse
559 | error, it will be provided here.
560 |
--------------------------------------------------------------------------------
/cli-packages/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 | !*.sh
4 | !SHASUMS.txt
5 |
--------------------------------------------------------------------------------
/cli-packages/SHASUMS.txt:
--------------------------------------------------------------------------------
1 | 0ae3441593318ff7388b3e954627f24f46e4edac90dbdf323422abb91f368b16 darwin-amd64.tar.gz
2 | 6ae6e7f789483e94a458057318dbc7dd59debe48d1a2be8620d9bd250db48b98 darwin-arm64.tar.gz
3 | d863809cdc52efa4c2bb5583d5588752f4dbbee0ffe03071f2a20d09d038151d linux-amd64.tar.gz
4 | c02934be1bcf0e23a36c84ead8be91b691028b1d39c83faba3987e069a1fa589 linux-arm64.tar.gz
5 | c7f43adb7b06d36d52650d5a8f6be0d12c52117e021506849d4fd4692df2056d windows-amd64.tar.gz
6 | 058c61da2dfe44154b36a9826d154fb9700f9868e77f72af98ff1ff48b18e46b windows-arm64.tar.gz
7 | bc1b263653fab6d8b0f484958040d4c160d0f8c266aa59efedfe9f44c396ea30 darwin/amd64/tier
8 | 6cdf5fc73e6e3844eca748528a0efdbcf9c050ad32258dbee8c362bc39b209f8 darwin/arm64/tier
9 | 3ff51e5d8a01b8d0175cd8d98035fff2926edfbe4688a9439ecc19b046f8d89a linux/amd64/tier
10 | d4d70107f05f35a85f551f43afbcf7a184a85fada6012a3e5dc2bba1e94c1030 linux/arm64/tier
11 | 63868761eac523d5eeaabd7f69e4b3fe5747ef2df4af445e76c01175243255b2 windows/amd64/tier
12 | 258d0111686eb829bf0120bba4897bf6460795c587317a0c74efdbfecbda220b windows/arm64/tier
13 |
--------------------------------------------------------------------------------
/cli-packages/create.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | OS=(linux darwin windows)
6 | ARCH=(amd64 arm64)
7 |
8 | for os in "${OS[@]}"; do
9 | for arch in "${ARCH[@]}"; do
10 | mkdir -p "$os/$arch"
11 | # TODO: put the shasums on S3 so we can skip downloading unchanged ones
12 | url=https://s3.amazonaws.com/tier.run/dl/tier.latest.$os-$arch.tar.gz
13 | curl -s $url > $os-$arch.tar.gz
14 | tar xvf $os-$arch.tar.gz -C "$os/$arch"
15 | done
16 | done
17 |
18 | shasum -a 256 *.tar.gz */*/tier > SHASUMS.txt
19 |
20 | myarch=$(uname -m)
21 | myos=$(uname | tr '[:upper:]' '[:lower:]')
22 | $myos/$myarch/tier version
23 | verFromTier=$($myos/$myarch/tier version 2>/dev/null)
24 | verDate="0.0.0-$(date +"%Y-%m-%d-%H-%M-%S")"
25 | version=${verFromTier:-$verDate}
26 |
27 | for os in "${OS[@]}"; do
28 | for arch in "${ARCH[@]}"; do
29 | cat > $os/$arch/package.json < $os/$arch/README.md < $os/$arch/index.js <dist/cjs/package.json <dist/cjs/version.d.ts <dist/cjs/version.js <dist/mjs/package.json <dist/mjs/version.d.ts <dist/mjs/version.js < (https://izs.me)",
34 | "license": "BSD-3-Clause",
35 | "scripts": {
36 | "prepare": "tsc -p tsconfig-cjs.json && tsc -p tsconfig-esm.json && bash fixup.sh",
37 | "format": "prettier --write . --loglevel warn",
38 | "test": "c8 tap",
39 | "snap": "c8 tap",
40 | "pretest": "npm run prepare",
41 | "presnap": "npm run prepare",
42 | "preversion": "npm test",
43 | "postversion": "npm run prepare && npm publish",
44 | "prepublishOnly": "git push origin --follow-tags",
45 | "postpublish": "rm -rf dist",
46 | "typedoc": "typedoc --tsconfig tsconfig-esm.json ./src/*.ts"
47 | },
48 | "prettier": {
49 | "semi": false,
50 | "printWidth": 80,
51 | "tabWidth": 2,
52 | "useTabs": false,
53 | "singleQuote": true,
54 | "jsxSingleQuote": false,
55 | "bracketSameLine": true,
56 | "arrowParens": "avoid",
57 | "endOfLine": "lf"
58 | },
59 | "eslintIgnore": [
60 | "/node_modules",
61 | "/build",
62 | "/public/build"
63 | ],
64 | "tap": {
65 | "coverage": false,
66 | "node-arg": [
67 | "--no-warnings",
68 | "--loader",
69 | "ts-node/esm"
70 | ],
71 | "ts": false
72 | },
73 | "devDependencies": {
74 | "@types/node": "^18.0.6",
75 | "@types/node-fetch": "^2.6.2",
76 | "@types/opener": "^1.4.0",
77 | "@types/tap": "^15.0.6",
78 | "actual-request-url": "^1.0.4",
79 | "c8": "^7.11.3",
80 | "eslint-config-prettier": "^8.5.0",
81 | "nock": "13.2",
82 | "prettier": "^2.6.2",
83 | "tap": "^16.3.4",
84 | "ts-node": "^10.9.1",
85 | "typedoc": "^0.23.20",
86 | "typescript": "^4.7.4"
87 | },
88 | "optionalDependencies": {
89 | "node-fetch": "^2.6.7"
90 | },
91 | "engines": {
92 | "node": ">=16"
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/scripts/reserve-stress-test.ts:
--------------------------------------------------------------------------------
1 | const { TIER_API_URL = 'http://127.0.0.1:8888', TIER_KEY = '' } =
2 | process.env
3 |
4 | Object.assign(process.env, {
5 | TIER_API_URL,
6 | TIER_KEY,
7 | })
8 |
9 | import { Headers } from 'node-fetch'
10 | import { equal } from 'node:assert'
11 | import {
12 | FeatureName,
13 | OrgName,
14 | PlanName,
15 | Reservation,
16 | TierClient,
17 | } from '../src/index'
18 |
19 | const N = 1000
20 | const refundEvery = 2
21 |
22 | const tier = TierClient.fromEnv()
23 | if (process.env.TIER_KEY === '') {
24 | tier.authorize = h => {
25 | const td = new Headers(h)
26 | td.delete('authorization')
27 | td.set('tier-domain', 'org:github:org:8051653')
28 | return td
29 | }
30 | }
31 |
32 | // feature: { count: [times] }
33 | let timing: { [k: string]: { [t: string]: number[] } } = {}
34 | const avg = (a: number[]): number => a.reduce((x, y) => x + y, 0) / a.length
35 | const reportTimes = () => {
36 | const ret = Object.fromEntries(
37 | Object.entries(timing).map(([f, c]) => {
38 | return [f, avg(Object.values(c).reduce((a, b) => a.concat(b), []))]
39 | })
40 | )
41 | timing = {}
42 | return ret
43 | }
44 |
45 | const plan: PlanName = `plan:stresstest@${Date.now()}`
46 | const model = {
47 | plans: {
48 | [plan]: {
49 | features: {
50 | 'feature:inf': {
51 | tiers: [{}],
52 | },
53 | 'feature:hundred': {
54 | tiers: [{ upto: 100 }],
55 | },
56 | 'feature:thousand': {
57 | tiers: [
58 | { upto: 100 },
59 | { upto: 200 },
60 | { upto: 300 },
61 | { upto: 400 },
62 | { upto: 500 },
63 | { upto: 600 },
64 | { upto: 700 },
65 | { upto: 800 },
66 | { upto: 900 },
67 | { upto: 1000 },
68 | ],
69 | },
70 | 'feature:10k': {
71 | tiers: [
72 | { upto: 1000 },
73 | { upto: 2000 },
74 | { upto: 3000 },
75 | { upto: 4000 },
76 | { upto: 5000 },
77 | { upto: 6000 },
78 | { upto: 7000 },
79 | { upto: 8000 },
80 | { upto: 9000 },
81 | { upto: 10000 },
82 | ],
83 | },
84 | },
85 | },
86 | },
87 | }
88 |
89 | const counts: { [k: string]: number } = {}
90 |
91 | let refundCounter: number = 0
92 |
93 | const eq = (a: any, b: any, c: any) => {
94 | let threw = true
95 | try {
96 | equal(a, b)
97 | threw = false
98 | } finally {
99 | if (threw) {
100 | console.error('failed assertion', a, b, c)
101 | }
102 | }
103 | }
104 |
105 | const res = async (o: OrgName, f: FeatureName, count = 1) => {
106 | if (!timing[f]) {
107 | timing[f] = {}
108 | }
109 | if (!timing[f][count]) {
110 | timing[f][count] = []
111 | }
112 | const start = performance.now()
113 | const rsv = await tier.reserve(o, f, count)
114 | timing[f][count].push(performance.now() - start)
115 |
116 | counts[f] = (counts[f] || 0) + count
117 | switch (f) {
118 | case 'feature:inf':
119 | eq(rsv.ok, true, rsv)
120 | break
121 | case 'feature:hundred':
122 | eq(rsv.ok, counts[f] <= 100, { rsv, count: counts[f] })
123 | break
124 | case 'feature:thousand':
125 | eq(rsv.ok, counts[f] <= 1000, { rsv, count: counts[f] })
126 | break
127 | case 'feature:10k':
128 | eq(rsv.ok, counts[f] <= 10000, { rsv, count: counts[f] })
129 | break
130 | }
131 |
132 | // refund every so often, or whenever a reservation is not allowed
133 | if (
134 | ++refundCounter % refundEvery === 0 ||
135 | Math.random() * refundEvery < 1 ||
136 | !rsv.ok
137 | ) {
138 | await refund(rsv)
139 | }
140 |
141 | return rsv
142 | }
143 |
144 | const refund = async (rsv: Reservation) => {
145 | timing.refund = timing.refund || {}
146 | timing.refund[0] = timing.refund[0] || []
147 | const start = performance.now()
148 | await rsv.refund()
149 | timing.refund[0].push(performance.now() - start)
150 | counts[rsv.feature] -= rsv.count
151 | counts.refundCount = counts.refundCount || 0
152 | counts.refundCount += 1
153 | counts.refundTotal = counts.refundTotal || 0
154 | counts.refundTotal += rsv.count
155 | }
156 |
157 | const checkUsed = async (o: OrgName, f: FeatureName) => {
158 | const rsv = await res(o, f, 0)
159 | eq(rsv.used, counts[f], { rsv, count: counts[f] })
160 | }
161 |
162 | const shuffle = (a: any[]): any[] => {
163 | const ret: any[] = []
164 | for (let i = 0; i < a.length; i++) {
165 | if (Math.random() < 0.5) ret.push(a[i])
166 | else ret.unshift(a[i])
167 | }
168 | return ret
169 | }
170 |
171 | const main = async () => {
172 | await tier.pushModel(model)
173 | console.log('pushed model')
174 | const org: OrgName = `org:stresstester:${Date.now()}`
175 |
176 | // wait for them to be available
177 | await new Promise(r => setTimeout(r, 5000))
178 | console.log('trying to appendPhase...')
179 | while (
180 | await tier
181 | .appendPhase(org, plan)
182 | .then(() => false)
183 | .catch(() => true)
184 | ) {
185 | process.stdout.write('.')
186 | }
187 | console.log('did appendPhase')
188 | await new Promise(r => setTimeout(r, 5000))
189 | console.log('trying first reserve...')
190 | let maxTries = 1000
191 | while (
192 | await res(org, 'feature:inf', 1)
193 | .then(() => false)
194 | .catch(() => true)
195 | ) {
196 | process.stdout.write('.')
197 | if (maxTries-- < 0) {
198 | await res(org, 'feature:inf', 1)
199 | break
200 | }
201 | }
202 | console.log('starting actual stress test')
203 |
204 | for (let i = 0; i < N; i++) {
205 | await Promise.all(
206 | shuffle([
207 | res(org, 'feature:inf', Math.floor(Math.random() * 10)),
208 | res(org, 'feature:hundred', Math.floor(Math.random() * 10)),
209 | res(org, 'feature:thousand', Math.floor(Math.random() * 10)),
210 | res(org, 'feature:10k', Math.floor(Math.random() * 10)),
211 | res(org, 'feature:inf', Math.floor(Math.random() * 100)),
212 | res(org, 'feature:hundred', Math.floor(Math.random() * 100)),
213 | res(org, 'feature:thousand', Math.floor(Math.random() * 100)),
214 | res(org, 'feature:10k', Math.floor(Math.random() * 100)),
215 | res(org, 'feature:inf', 1),
216 | res(org, 'feature:hundred', 1),
217 | res(org, 'feature:thousand', 1),
218 | res(org, 'feature:10k', 1),
219 | res(org, 'feature:inf', 0),
220 | res(org, 'feature:hundred', 0),
221 | res(org, 'feature:thousand', 0),
222 | res(org, 'feature:10k', 1),
223 | ])
224 | )
225 | await Promise.all([
226 | checkUsed(org, 'feature:inf'),
227 | checkUsed(org, 'feature:hundred'),
228 | checkUsed(org, 'feature:thousand'),
229 | checkUsed(org, 'feature:10k'),
230 | ])
231 | if (i % 100 === 0) {
232 | console.log(i, { counts, responseTimes: reportTimes() })
233 | }
234 | }
235 | console.log('ok')
236 | }
237 |
238 | main()
239 |
--------------------------------------------------------------------------------
/src/answer.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module answer
3 | */
4 | import { Tier, TierError } from './client.js'
5 | import { FeatureName, OrgName, ReportParams, Usage } from './tier-types.js'
6 |
7 | /**
8 | * The object returned by the {@link client.Tier.can } method.
9 | * Should not be instantiated directly.
10 | */
11 | export class Answer {
12 | /**
13 | * Indicates that the org is not over their limit for the feature
14 | * Note that when an error occurs, `ok` will be set to `true`,
15 | * so that we fail open by default. In order to prevent access
16 | * on API failures, you must check *both* `answer.ok` *and*
17 | * `answer.err`.
18 | */
19 | ok: boolean
20 | /**
21 | * The feature checked by {@link client.Tier.can }
22 | */
23 | feature: FeatureName
24 | /**
25 | * The org checked by {@link client.Tier.can }
26 | */
27 | org: OrgName
28 | /**
29 | * Reference to the {@link client.Tier} client in use.
30 | * @internal
31 | */
32 | client: Tier
33 | /**
34 | * Any error encountered during the feature limit check.
35 | * Note that when an error occurs, `ok` will be set to `true`,
36 | * so that we fail open by default. In order to prevent access
37 | * on API failures, you must check *both* `answer.ok` *and*
38 | * `answer.err`.
39 | */
40 | err?: TierError
41 |
42 | constructor(
43 | client: Tier,
44 | org: OrgName,
45 | feature: FeatureName,
46 | usage?: Usage,
47 | err?: TierError
48 | ) {
49 | this.client = client
50 | this.org = org
51 | this.feature = feature
52 | if (usage && !err) {
53 | this.ok = usage.used < usage.limit
54 | } else {
55 | this.ok = true
56 | this.err = err
57 | }
58 | }
59 |
60 | /**
61 | * Report usage for the org and feature checked by {@link client.Tier.can}
62 | */
63 | public async report(n: number = 1, options?: ReportParams) {
64 | return this.client.report(this.org, this.feature, n, options)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/backoff.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Used for backoff in {@link client.TierWithClock.advance}
3 | */
4 | export class Backoff {
5 | signal?: AbortSignal
6 | maxDelay: number
7 | maxTotalDelay: number
8 | totalDelay: number = 0
9 | count: number = 0
10 | timer?: ReturnType
11 | resolve?: () => void
12 | timedOut: boolean = false
13 | constructor(
14 | maxDelay: number,
15 | maxTotalDelay: number,
16 | { signal }: { signal?: AbortSignal }
17 | ) {
18 | this.maxTotalDelay = maxTotalDelay
19 | this.maxDelay = maxDelay
20 | this.signal = signal
21 | signal?.addEventListener('abort', () => this.abort())
22 | }
23 | abort() {
24 | const { timer, resolve } = this
25 | this.resolve = undefined
26 | this.timer = undefined
27 | if (timer) clearTimeout(timer)
28 | if (resolve) resolve()
29 | this.count = 0
30 | }
31 | async backoff() {
32 | // this max total delay is just a convenient safety measure,
33 | // running tests for 30 seconds is entirely unreasonable.
34 | /* c8 ignore start */
35 | const rem = this.maxTotalDelay - this.totalDelay
36 | if (rem <= 0 && !this.timedOut) {
37 | this.timedOut = true
38 | throw new Error('exceeded maximum backoff timeout')
39 | }
40 | // this part does get tested, but it's a race as to whether it
41 | // ends up getting to this point, or aborting the fetch and
42 | // throwing before ever calling backoff()
43 | if (this.timedOut || this.signal?.aborted) {
44 | return
45 | }
46 | /* c8 ignore stop */
47 | this.count++
48 | const delay = Math.max(
49 | 0,
50 | Math.min(
51 | this.maxDelay,
52 | rem,
53 | Math.ceil(Math.pow(this.count, 2) * 10 * (Math.random() + 0.5))
54 | )
55 | )
56 | this.totalDelay += delay
57 | await new Promise(res => {
58 | this.resolve = res
59 | this.timer = setTimeout(res, delay)
60 | })
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/client.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module client
3 | */
4 |
5 | // just the client bits, assuming that the sidecar is already
6 | // initialized and running somewhere.
7 |
8 | import { git, version } from './version.js'
9 |
10 | const defaultUserAgent = `tier/${version} ${git.substring(0, 8)} ${
11 | typeof navigator !== 'undefined'
12 | ? navigator.userAgent
13 | : typeof process !== 'undefined'
14 | ? `node/${process.version}`
15 | : /* c8 ignore start */
16 | ''
17 | /* c8 ignore stop */
18 | }`.trim()
19 |
20 | import { Answer } from './answer.js'
21 | import { Backoff } from './backoff.js'
22 | import { isTierError, TierError } from './tier-error.js'
23 |
24 | import {
25 | CancelPhase,
26 | CheckoutParams,
27 | CheckoutRequest,
28 | CheckoutResponse,
29 | ClockRequest,
30 | ClockResponse,
31 | CurrentPhase,
32 | CurrentPhaseResponse,
33 | FeatureName,
34 | Features,
35 | Limits,
36 | LookupOrgResponse,
37 | LookupOrgResponseJSON,
38 | Model,
39 | OrgInfo,
40 | OrgInfoJSON,
41 | OrgName,
42 | PaymentMethodsResponse,
43 | PaymentMethodsResponseJSON,
44 | Phase,
45 | Plan,
46 | PlanName,
47 | PushResponse,
48 | ReportParams,
49 | ReportRequest,
50 | ReportResponse,
51 | ScheduleParams,
52 | ScheduleRequest,
53 | ScheduleResponse,
54 | SubscribeParams,
55 | Usage,
56 | WhoAmIResponse,
57 | WhoIsResponse,
58 | } from './tier-types.js'
59 |
60 | /* c8 ignore start */
61 | export { Answer } from './answer.js'
62 | export { isErrorResponse, isTierError, TierError } from './tier-error.js'
63 | export {
64 | isAggregate,
65 | isDivide,
66 | isFeatureDefinition,
67 | isFeatureName,
68 | isFeatureNameVersioned,
69 | isFeatures,
70 | isFeatureTier,
71 | isInterval,
72 | isMode,
73 | isModel,
74 | isOrgName,
75 | isPhase,
76 | isPlan,
77 | isPlanName,
78 | validateDivide,
79 | validateFeatureDefinition,
80 | validateFeatureTier,
81 | validateModel,
82 | validatePlan,
83 | } from './tier-types.js'
84 | export type {
85 | Aggregate,
86 | CancelPhase,
87 | CheckoutParams,
88 | CheckoutResponse,
89 | CurrentPhase,
90 | CurrentPhaseResponse,
91 | Divide,
92 | FeatureDefinition,
93 | FeatureName,
94 | FeatureNameVersioned,
95 | Features,
96 | FeatureTier,
97 | Interval,
98 | Limits,
99 | LookupOrgResponse,
100 | Mode,
101 | Model,
102 | OrgInfo,
103 | OrgName,
104 | PaymentMethodsResponse,
105 | Phase,
106 | Plan,
107 | PlanName,
108 | PushResponse,
109 | ReportParams,
110 | ReportResponse,
111 | ScheduleParams,
112 | ScheduleResponse,
113 | SubscribeParams,
114 | Usage,
115 | WhoAmIResponse,
116 | WhoIsResponse,
117 | } from './tier-types.js'
118 | /* c8 ignore stop */
119 |
120 | let warnedPullLatest = false
121 | const warnPullLatest = () => {
122 | if (warnedPullLatest) return
123 | warnedPullLatest = true
124 | emitWarning(
125 | 'pullLatest is deprecated, and will be removed in the next version',
126 | 'DeprecationWarning',
127 | '',
128 | Tier.prototype.pullLatest
129 | )
130 | }
131 | const warnedDeprecated = new Set()
132 | const warnDeprecated = (n: string, instead: string) => {
133 | if (warnedDeprecated.has(n)) return
134 | warnedDeprecated.add(n)
135 | emitWarning(
136 | `Tier.${n} is deprecated. Please use Tier.${instead} instead.`,
137 | 'DeprecationWarning',
138 | '',
139 | Tier.prototype[n as keyof Tier] as () => {}
140 | )
141 | }
142 | const emitWarning = (
143 | msg: string,
144 | warningType: string,
145 | code: string,
146 | fn: (...a: any[]) => any
147 | ) => {
148 | typeof process === 'object' &&
149 | process &&
150 | typeof process.emitWarning === 'function'
151 | ? process.emitWarning(msg, warningType, code, fn)
152 | : /* c8 ignore start */
153 | console.error(msg)
154 | /* c8 ignore stop */
155 | }
156 |
157 | // turn an orginfo object into a suitable json request
158 | const orgInfoToJSON = (o: OrgInfo): OrgInfoJSON => {
159 | const { invoiceSettings, ...clean } = o
160 | return {
161 | ...clean,
162 | invoice_settings: {
163 | default_payment_method: invoiceSettings?.defaultPaymentMethod || '',
164 | },
165 | }
166 | }
167 |
168 | // turn a camelCase CheckoutParams into a snake_case CheckoutRequest
169 | const checkoutParamsToJSON = (
170 | c: CheckoutParams
171 | ): Omit => {
172 | return {
173 | cancel_url: c.cancelUrl,
174 | require_billing_address: c.requireBillingAddress,
175 | tax: c.tax
176 | ? {
177 | automatic: c.tax.automatic,
178 | collect_id: c.tax.collectId,
179 | }
180 | : undefined,
181 | }
182 | }
183 |
184 | // turn the json from a lookup org response into humane form
185 | const lookupOrgResponseFromJSON = (
186 | d: LookupOrgResponseJSON
187 | ): LookupOrgResponse => {
188 | const { invoice_settings, ...cleaned } = d
189 | return {
190 | ...cleaned,
191 | invoiceSettings: {
192 | defaultPaymentMethod: invoice_settings?.default_payment_method || '',
193 | },
194 | }
195 | }
196 |
197 | const paymentMethodsFromJSON = (
198 | r: PaymentMethodsResponseJSON
199 | ): PaymentMethodsResponse => {
200 | return {
201 | org: r.org,
202 | methods: r.methods || [],
203 | }
204 | }
205 |
206 | // turn (features, {trialDays, effective}) into a set of phases
207 | const featuresToPhases = (
208 | features: Features | Features[],
209 | {
210 | trialDays,
211 | effective,
212 | }: {
213 | trialDays?: number
214 | effective?: Date
215 | }
216 | ): Phase[] => {
217 | const phases: Phase[] = !Array.isArray(features)
218 | ? [{ features: [features], effective }]
219 | : features.length
220 | ? [{ features, effective }]
221 | : []
222 |
223 | if (trialDays !== undefined) {
224 | if (typeof trialDays !== 'number' || trialDays <= 0) {
225 | throw new TypeError('trialDays must be number >0 if specified')
226 | }
227 | if (!phases.length) {
228 | throw new TypeError('trialDays may not be set without a subscription')
229 | }
230 | const real = phases[0]
231 | const { effective } = real
232 | const start = (effective || new Date()).getTime()
233 | const offset = 1000 * 60 * 60 * 24 * trialDays
234 | real.effective = new Date(start + offset)
235 | phases.unshift({ ...real, trial: true, effective })
236 | }
237 |
238 | return phases
239 | }
240 |
241 | // XXX remove in v6
242 | const versionIsNewer = (oldV: string | undefined, newV: string): boolean => {
243 | if (!oldV) {
244 | return true
245 | }
246 | const oldN = /^0$|^[1-9][0-9]*$/.test(oldV) ? parseInt(oldV, 10) : NaN
247 | const newN = /^0$|^[1-9][0-9]*$/.test(newV) ? parseInt(newV, 10) : NaN
248 | // this will return false if either are NaN
249 | return oldN < newN
250 | ? true
251 | : newN < oldN
252 | ? false
253 | : newV.localeCompare(oldV, 'en') > 0
254 | }
255 |
256 | /**
257 | * Tier constructor options for cases where the baseURL is
258 | * set by the environment.
259 | */
260 | export interface TierGetClientOptions {
261 | baseURL?: string
262 | apiKey?: string
263 | fetchImpl?: typeof fetch
264 | debug?: boolean
265 | onError?: (er: TierError) => any
266 | signal?: AbortSignal
267 | userAgent?: string | false
268 | }
269 |
270 | /**
271 | * Options for the Tier constructor. Same as {@link TierGetClientOptions},
272 | * but baseURL is required.
273 | */
274 | export interface TierOptions extends TierGetClientOptions {
275 | baseURL: string
276 | }
277 |
278 | export interface TierWithClockOptions extends TierOptions {
279 | clockID: string
280 | }
281 |
282 | /**
283 | * The Tier client, main interface provided by the SDK.
284 | *
285 | * All methods are re-exported as top level functions by the main
286 | * package export, in such a way that they create a client and
287 | * spin up a Tier sidecar process on demand.
288 | */
289 | export class Tier {
290 | /**
291 | * The `fetch()` implementation in use. Will default to the Node
292 | * built-in `fetch` if available, otherwise `node-fetch` will be used.
293 | */
294 | readonly fetch: typeof fetch
295 |
296 | /**
297 | * The URL to the sidecar providing API endpoints
298 | */
299 | readonly baseURL: string
300 |
301 | /**
302 | * A method which is called on all errors, useful for logging,
303 | * or handling 401 responses from a public tier API.
304 | */
305 | onError?: (er: TierError) => any
306 |
307 | /**
308 | * API key for use with the hosted service on tier.run
309 | */
310 | readonly apiKey: string
311 |
312 | /**
313 | * AbortSignal used to cancel all requests from this client.
314 | */
315 | signal?: AbortSignal
316 |
317 | /**
318 | * The User-Agent header that Tier sends.
319 | *
320 | * Set to `false` to not send a user agent header. Leave blank to
321 | * send the default User-Agent header, which includes the Tier client
322 | * version, along with either Node.js process.version or browser's
323 | * navigator.userAgent
324 | */
325 | userAgent?: string | false
326 |
327 | /**
328 | * Create a new Tier client. Set `{ debug: true }` in the
329 | * options object to enable debugging output.
330 | */
331 | constructor({
332 | baseURL,
333 | apiKey = '',
334 | fetchImpl = globalThis.fetch,
335 | debug = false,
336 | onError,
337 | signal,
338 | userAgent = defaultUserAgent,
339 | }: TierOptions) {
340 | this.fetch = fetchImpl
341 | this.debug = !!debug
342 | this.baseURL = baseURL
343 | this.apiKey = apiKey
344 | this.onError = onError
345 | this.signal = signal
346 | this.userAgent = userAgent
347 | }
348 |
349 | async withClock(
350 | name: string,
351 | start: Date = new Date()
352 | ): Promise {
353 | const c = await this.tryPost('/v1/clock', {
354 | name,
355 | present: start,
356 | })
357 | return new TierWithClock({
358 | baseURL: this.baseURL,
359 | apiKey: this.apiKey,
360 | fetchImpl: this.fetch,
361 | debug: this.debug,
362 | onError: this.onError,
363 | signal: this.signal,
364 | clockID: c.id,
365 | })
366 | }
367 |
368 | /* c8 ignore start */
369 | protected debugLog(..._: any[]): void {}
370 | /* c8 ignore stop */
371 | protected set debug(d: boolean) {
372 | if (d) {
373 | this.debugLog = (...m: any[]) => console.info('tier:', ...m)
374 | } else {
375 | this.debugLog = (..._: any[]) => {}
376 | }
377 | }
378 |
379 | protected async apiGet(
380 | path: string,
381 | query?: { [k: string]: string | string[] }
382 | ): Promise {
383 | const u = new URL(path, this.baseURL)
384 | if (query) {
385 | for (const [k, v] of Object.entries(query)) {
386 | /* c8 ignore start */
387 | if (Array.isArray(v)) {
388 | for (const value of v) {
389 | u.searchParams.append(k, value)
390 | }
391 | /* c8 ignore stop */
392 | } else {
393 | u.searchParams.set(k, v)
394 | }
395 | }
396 | }
397 | this.debugLog('GET', u.toString())
398 | const { fetch } = this
399 | /* c8 ignore start */
400 | const ctx = typeof window === 'undefined' ? globalThis : window
401 | /* c8 ignore stop */
402 | let res: Awaited>
403 | let text: string
404 | try {
405 | const fo = fetchOptions(this)
406 | res = await fetch.call(ctx, u.toString(), fo)
407 | text = await res.text()
408 | } catch (er) {
409 | throw new TierError(path, query, 0, (er as Error).message, er)
410 | }
411 | let responseData: any
412 | try {
413 | responseData = JSON.parse(text)
414 | } catch (er) {
415 | responseData = text || (er as Error).message
416 | throw new TierError(path, query, res.status, text, er)
417 | }
418 | if (res.status !== 200) {
419 | throw new TierError(path, query, res.status, responseData)
420 | }
421 | return responseData as T
422 | }
423 | protected async tryGet(
424 | path: string,
425 | query?: { [k: string]: string | string[] }
426 | ): Promise {
427 | const p = this.apiGet(path, query)
428 | const onError = this.onError
429 | return !onError ? p : p.catch(er => onError(er))
430 | }
431 |
432 | protected async apiPost(
433 | path: string,
434 | body: TReq
435 | ): Promise {
436 | const u = new URL(path, this.baseURL)
437 | const { fetch } = this
438 | /* c8 ignore start */
439 | const ctx = typeof window === 'undefined' ? globalThis : window
440 | /* c8 ignore stop */
441 |
442 | let res: Awaited>
443 | let text: string
444 | this.debugLog('POST', u.pathname, body)
445 | try {
446 | res = await fetch.call(
447 | ctx,
448 | u.toString(),
449 | fetchOptions(this, {
450 | method: 'POST',
451 | headers: {
452 | 'content-type': 'application/json',
453 | },
454 | body: JSON.stringify(body),
455 | })
456 | )
457 | text = await res.text()
458 | } catch (er) {
459 | throw new TierError(path, body, 0, (er as Error).message, er)
460 | }
461 | let responseData: any
462 | try {
463 | responseData = JSON.parse(text)
464 | } catch (er) {
465 | responseData = text || (er as Error).message
466 | throw new TierError(path, body, res.status, responseData, er)
467 | }
468 | if (res.status < 200 || res.status > 299) {
469 | throw new TierError(path, body, res.status, responseData)
470 | }
471 | return responseData as TRes
472 | }
473 |
474 | protected async tryPost(
475 | path: string,
476 | body: TReq
477 | ): Promise {
478 | const p = this.apiPost(path, body)
479 | const onError = this.onError
480 | return !onError ? p : p.catch(er => onError(er))
481 | }
482 |
483 | /**
484 | * Look up the limits for all features for a given {@link types.OrgName}
485 | */
486 | async lookupLimits(org: OrgName): Promise {
487 | return await this.tryGet('/v1/limits', { org })
488 | }
489 |
490 | /**
491 | * Look up the payment methods on file for a given {@link types.OrgName}
492 | */
493 | async lookupPaymentMethods(org: OrgName): Promise {
494 | return paymentMethodsFromJSON(
495 | await this.tryGet('/v1/payment_methods', {
496 | org,
497 | })
498 | )
499 | }
500 |
501 | /**
502 | * Look up limits for a given {@link types.FeatureName} and {@link types.OrgName}
503 | */
504 | async lookupLimit(org: OrgName, feature: FeatureName): Promise {
505 | const limits = await this.tryGet('/v1/limits', { org })
506 | for (const usage of limits.usage) {
507 | if (
508 | usage.feature === feature ||
509 | usage.feature.startsWith(`${feature}@plan:`)
510 | ) {
511 | return usage
512 | }
513 | }
514 | return { feature, used: 0, limit: 0 }
515 | }
516 |
517 | /**
518 | * Report metered feature usage
519 | */
520 | public async report(
521 | org: OrgName,
522 | feature: FeatureName,
523 | n: number = 1,
524 | { at, clobber }: ReportParams = {}
525 | ): Promise<{}> {
526 | const req: ReportRequest = {
527 | org,
528 | feature,
529 | n,
530 | }
531 | if (at) {
532 | req.at = at
533 | }
534 | req.clobber = !!clobber
535 | return await this.tryPost('/v1/report', req)
536 | }
537 |
538 | /**
539 | * Generate a checkout URL to set an org's payment info, and optionally
540 | * to create a subscription on completion.
541 | *
542 | * `successUrl` param should be a URL within your application where the
543 | * user will be redirected upon completion.
544 | */
545 | public async checkout(
546 | org: OrgName,
547 | successUrl: string,
548 | params: CheckoutParams = {}
549 | ): Promise {
550 | const cr: CheckoutRequest = {
551 | org,
552 | success_url: successUrl,
553 | ...checkoutParamsToJSON(params),
554 | }
555 | const { features, trialDays } = params
556 | if (features) {
557 | cr.features = Array.isArray(features) ? features : [features]
558 | cr.trial_days = trialDays
559 | }
560 | return await this.tryPost(
561 | '/v1/checkout',
562 | cr
563 | )
564 | }
565 |
566 | /**
567 | * Simple interface for creating a new phase in the org's subscription
568 | * schedule.
569 | *
570 | * Setting `trialDays` will cause it to prepend a "trial" phase on the
571 | * effective date, and delay the creation of the actual non-trial
572 | * subscription phase by the specified number of days.
573 | */
574 | public async subscribe(
575 | org: OrgName,
576 | features: Features | Features[],
577 | { effective, info, trialDays, paymentMethodID }: SubscribeParams = {}
578 | ): Promise {
579 | return await this.schedule(
580 | org,
581 | featuresToPhases(features, { effective, trialDays }),
582 | { info, paymentMethodID }
583 | )
584 | }
585 |
586 | /**
587 | * Cancel an org's subscriptions
588 | */
589 | public async cancel(org: OrgName) {
590 | const cp: CancelPhase = {}
591 | const sr: ScheduleRequest = { org, phases: [cp] }
592 | return await this.tryPost(
593 | '/v1/subscribe',
594 | sr
595 | )
596 | }
597 |
598 | /**
599 | * Advanced interface for creating arbitrary schedule phases in any
600 | * order.
601 | */
602 | public async schedule(
603 | org: OrgName,
604 | phases?: Phase[],
605 | { info, paymentMethodID }: ScheduleParams = {}
606 | ) {
607 | const sr: ScheduleRequest = {
608 | org,
609 | phases,
610 | info: info ? orgInfoToJSON(info) : undefined,
611 | payment_method_id: paymentMethodID,
612 | }
613 | return await this.tryPost(
614 | '/v1/subscribe',
615 | sr
616 | )
617 | }
618 |
619 | /**
620 | * Update an org's metadata. Note that any fields not set (other than
621 | * `metadata`) will be reset to empty `''` values on any update.
622 | */
623 | public async updateOrg(org: OrgName, info: OrgInfo) {
624 | const sr: ScheduleRequest = {
625 | org,
626 | info: orgInfoToJSON(info),
627 | }
628 | return await this.tryPost(
629 | '/v1/subscribe',
630 | sr
631 | )
632 | }
633 |
634 | /**
635 | * Get an org's billing provider identifier
636 | */
637 | public async whois(org: OrgName): Promise {
638 | return await this.tryGet('/v1/whois', { org })
639 | }
640 |
641 | // note: same endpoint as whois, but when include=info is set, this hits
642 | // stripe every time and cannot be cached.
643 | /**
644 | * Look up all {@link types.OrgInfo} metadata about an org
645 | */
646 | public async lookupOrg(org: OrgName): Promise {
647 | return lookupOrgResponseFromJSON(
648 | await this.tryGet('/v1/whois', {
649 | org,
650 | include: 'info',
651 | })
652 | )
653 | }
654 |
655 | /**
656 | * Fetch the current phase for an org
657 | */
658 | public async lookupPhase(org: OrgName): Promise {
659 | const resp = await this.tryGet('/v1/phase', { org })
660 | const end = resp.end !== undefined ? new Date(resp.end) : resp.end
661 | const current = resp.current && {
662 | effective: new Date(resp.current.effective),
663 | end: new Date(resp.current.end),
664 | }
665 | return {
666 | ...resp,
667 | effective: new Date(resp.effective),
668 | end,
669 | trial: !!resp.trial,
670 | current,
671 | }
672 | }
673 |
674 | /**
675 | * Pull the full {@link types.Model} pushed to Tier
676 | */
677 | public async pull(): Promise {
678 | return this.tryGet('/v1/pull')
679 | }
680 |
681 | /**
682 | * Similar to {@link Tier.pull}, but filters plans to only include
683 | * the highest version of each plan. Plan versions are sorted numerically
684 | * if they are decimal integers, or lexically in the `en` locale otherwise.
685 | *
686 | * So, for example, the plan version `20test` will be considered "lower"
687 | * than `9test`, because the non-numeric string causes it to be lexically
688 | * sorted. But the plan version `20` sill be considered "higher" than the
689 | * plan version `9`, because both are strictly numeric.
690 | *
691 | * **Note** Plan versions are inherently arbitrary, and as such, they really
692 | * should not be sorted or given any special priority by being "latest".
693 | *
694 | * This method will be removed in version 6 of this SDK.
695 | *
696 | * @deprecated
697 | */
698 | public async pullLatest(): Promise {
699 | warnPullLatest()
700 | const model = await this.pull()
701 | const plans: { [k: PlanName]: Plan } = Object.create(null)
702 | const latest: { [k: string]: string } = Object.create(null)
703 | for (const id of Object.keys(model.plans)) {
704 | const [name, version] = id.split('@')
705 | if (versionIsNewer(latest[name], version)) {
706 | latest[name] = version
707 | }
708 | }
709 | for (const [name, version] of Object.entries(latest)) {
710 | const id = `${name}@${version}` as PlanName
711 | plans[id] = model.plans[id]
712 | }
713 | return { plans }
714 | }
715 |
716 | /**
717 | * Push a new {@link types.Model} to Tier
718 | *
719 | * Any previously pushed {@link types.PlanName} will be ignored, new
720 | * plans will be added.
721 | */
722 | public async push(model: Model): Promise {
723 | return await this.tryPost('/v1/push', model)
724 | }
725 |
726 | /**
727 | * Get information about the current sidecare API in use
728 | */
729 | public async whoami(): Promise {
730 | return await this.tryGet('/v1/whoami')
731 | }
732 |
733 | /**
734 | * Return an {@link answer.Answer} indicating whether an org can
735 | * access a feature, or if they are at their plan limit.
736 | */
737 | public async can(org: OrgName, feature: FeatureName): Promise {
738 | try {
739 | const usage = await this.lookupLimit(org, feature)
740 | return new Answer(this, org, feature, usage)
741 | } catch (err) {
742 | /* c8 ignore start */
743 | // something extra broken, just fail. should be impossible.
744 | if (!isTierError(err)) {
745 | throw err
746 | }
747 | /* c8 ignore stop */
748 | return new Answer(this, org, feature, undefined, err)
749 | }
750 | }
751 |
752 | /* c8 ignore start */
753 | /**
754 | * @deprecated alias for {@link Tier#lookupLimits}
755 | */
756 | public async limits(org: OrgName): Promise {
757 | warnDeprecated('limits', 'lookupLimits')
758 | return this.lookupLimits(org)
759 | }
760 | /**
761 | * @deprecated alias for {@link Tier#lookupLimit}
762 | */
763 | public async limit(org: OrgName, feature: FeatureName): Promise {
764 | warnDeprecated('limit', 'lookupLimit')
765 | return this.lookupLimit(org, feature)
766 | }
767 | /**
768 | * @deprecated alias for {@link Tier#lookupPhase}
769 | */
770 | public async phase(org: OrgName): Promise {
771 | warnDeprecated('phase', 'lookupPhase')
772 | return this.lookupPhase(org)
773 | }
774 | /* c8 ignore stop */
775 | }
776 |
777 | export class TierWithClock extends Tier {
778 | /**
779 | * The ID of a stripe test clock. Set via {@link withClock}
780 | */
781 | clockID: string
782 |
783 | constructor(options: TierWithClockOptions) {
784 | super(options)
785 | this.clockID = options.clockID
786 | /* c8 ignore start */
787 | if (!this.clockID) {
788 | throw new TypeError('no clockID found in TierWithClock constructor')
789 | }
790 | /* c8 ignore stop */
791 | }
792 |
793 | async advance(t: Date): Promise {
794 | await this.tryPost('/v1/clock', {
795 | id: this.clockID,
796 | present: t,
797 | })
798 | await this.#awaitClockReady()
799 | }
800 |
801 | async #awaitClockReady(): Promise {
802 | const bo = new Backoff(5000, 30000, { signal: this.signal })
803 | while (!this.signal?.aborted) {
804 | const cr = await this.#syncClock()
805 | if (cr.status === 'ready') {
806 | return
807 | }
808 | await bo.backoff()
809 | }
810 | }
811 |
812 | async #syncClock(): Promise {
813 | const id = this.clockID
814 | return await this.tryGet('/v1/clock', { id })
815 | }
816 | }
817 |
818 | const fetchOptions = (
819 | tier: Tier | TierWithClock,
820 | settings: RequestInit = {}
821 | ): RequestInit => {
822 | const { apiKey, signal } = tier
823 | const clockID = tier instanceof TierWithClock ? tier.clockID : undefined
824 |
825 | const authHeader = apiKey
826 | ? { authorization: `Basic ${base64(apiKey + ':')}` }
827 | : {}
828 |
829 | const clockHeader = clockID ? { 'tier-clock': clockID } : {}
830 | return {
831 | ...settings,
832 | credentials: 'include',
833 | mode: 'cors',
834 | signal,
835 | headers: {
836 | ...(settings.headers || {}),
837 | ...(tier.userAgent ? { 'user-agent': tier.userAgent } : {}),
838 | ...authHeader,
839 | ...clockHeader,
840 | } as HeadersInit,
841 | }
842 | }
843 |
844 | /* c8 ignore start */
845 | const base64 = (s: string): string =>
846 | typeof window !== 'undefined'
847 | ? window.btoa(s)
848 | : Buffer.from(s).toString('base64')
849 | /* c8 ignore stop */
850 |
--------------------------------------------------------------------------------
/src/get-client.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Internal module for starting a Tier API sidecar on demand.
3 | *
4 | * This should not be used directly.
5 | *
6 | * @internal
7 | * @module
8 | */
9 |
10 | // TODO: use the built-in tier binary for the appropriate platform
11 | // can do the platform-specific optional dep trick.
12 |
13 | // get a client on-demand for servicing the top-level methods.
14 | //
15 | // This spawns a sidecar on localhost as needed, if baseURL is not set.
16 |
17 | import type { ChildProcess } from 'child_process'
18 | import { Tier, TierGetClientOptions } from './client.js'
19 |
20 | // just use node-fetch as a polyfill for old node environments
21 | let fetchPromise: Promise | null = null
22 | let FETCH = global.fetch
23 | if (typeof FETCH !== 'function') {
24 | fetchPromise = import('node-fetch').then(f => {
25 | //@ts-ignore
26 | FETCH = f.default
27 | fetchPromise = null
28 | })
29 | }
30 |
31 | // fill-in for browser bundlers
32 | /* c8 ignore start */
33 | const PROCESS =
34 | typeof process === 'undefined'
35 | ? {
36 | pid: 1,
37 | env: {
38 | TIER_DEBUG: '',
39 | NODE_DEBUG: '',
40 | TIER_API_KEY: '',
41 | TIER_BASE_URL: '',
42 | TIER_LIVE: '',
43 | STRIPE_DEBUG: '',
44 | },
45 | on: () => {},
46 | removeListener: () => {},
47 | kill: () => {},
48 | }
49 | : process
50 | /* c8 ignore start */
51 |
52 | const port = 10000 + (PROCESS.pid % 10000)
53 | let sidecarPID: number | undefined
54 | let initting: undefined | Promise
55 |
56 | const debug =
57 | PROCESS.env.TIER_DEBUG === '1' ||
58 | /\btier\b/i.test(PROCESS.env.NODE_DEBUG || '')
59 | const debugLog = debug
60 | ? (...m: any[]) => console.error('tier:', ...m)
61 | : () => {}
62 |
63 | export const getClient = async (
64 | clientOptions: TierGetClientOptions = {}
65 | ): Promise => {
66 | const { TIER_BASE_URL, TIER_API_KEY } = PROCESS.env
67 | let { baseURL = TIER_BASE_URL, apiKey = TIER_API_KEY } = clientOptions
68 | baseURL = baseURL || (apiKey ? 'https://api.tier.run' : undefined)
69 | if (!baseURL) {
70 | await init()
71 | baseURL = PROCESS.env.TIER_BASE_URL
72 | }
73 | if (!baseURL) {
74 | throw new Error('failed sidecar initialization')
75 | }
76 | return new Tier({
77 | debug,
78 | fetchImpl: FETCH,
79 | ...clientOptions,
80 | baseURL,
81 | apiKey,
82 | })
83 | }
84 |
85 | // evade clever bundlers that try to import child_process for the client
86 | // insist that this is always a dynamic import, even though we don't
87 | // actually ever set this to any different value.
88 | let child_process: string = 'child_process'
89 |
90 | /**
91 | * Initialize the Tier sidecar.
92 | *
93 | * Exported for testing, do not call directly.
94 | *
95 | * @internal
96 | */
97 | export const init = async () => {
98 | /* c8 ignore start */
99 | if (!FETCH) {
100 | await fetchPromise
101 | if (!FETCH) {
102 | throw new Error('could not find a fetch implementation')
103 | }
104 | }
105 | /* c8 ignore stop */
106 |
107 | if (sidecarPID || PROCESS.env.TIER_BASE_URL) {
108 | return
109 | }
110 | if (initting) {
111 | return initting
112 | }
113 | initting = import(child_process)
114 | .then(({ spawn }) => {
115 | const args = PROCESS.env.TIER_LIVE === '1' ? ['--live'] : []
116 | const env = Object.fromEntries(Object.entries(PROCESS.env))
117 | if (debug) {
118 | args.push('-v')
119 | env.STRIPE_DEBUG = '1'
120 | }
121 | args.push('serve', '--addr', `127.0.0.1:${port}`)
122 | debugLog(args)
123 | return new Promise((res, rej) => {
124 | let proc = spawn('tier', args, {
125 | env,
126 | stdio: ['ignore', 'pipe', 'inherit'],
127 | })
128 | proc.on('error', rej)
129 | /* c8 ignore start */
130 | if (!proc || !proc.stdout) {
131 | return rej(new Error('failed to start tier sidecar'))
132 | }
133 | /* c8 ignore stop */
134 | proc.stdout.on('data', () => res(proc))
135 | })
136 | })
137 | .then(proc => {
138 | debugLog('started sidecar', proc.pid)
139 | proc.on('exit', () => {
140 | debugLog('sidecar closed', sidecarPID)
141 | sidecarPID = undefined
142 | delete PROCESS.env.TIER_BASE_URL
143 | PROCESS.removeListener('exit', exitHandler)
144 | })
145 | PROCESS.on('exit', exitHandler)
146 | proc.unref()
147 | PROCESS.env.TIER_BASE_URL = `http://127.0.0.1:${port}`
148 | sidecarPID = proc.pid
149 | initting = undefined
150 | })
151 | .catch(er => {
152 | debugLog('sidecar error', er)
153 | initting = undefined
154 | sidecarPID = undefined
155 | throw er
156 | })
157 | return initting
158 | }
159 |
160 | /**
161 | * Method to shut down the auto-started sidecar process on
162 | * exit. Exported for testing, do not call directly.
163 | *
164 | * @internal
165 | */
166 | /* c8 ignore start */
167 | export const exitHandler = (_: number, signal: string | null) => {
168 | if (sidecarPID) {
169 | PROCESS.kill(sidecarPID, signal || 'SIGTERM')
170 | }
171 | }
172 | /* c8 ignore stop */
173 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { getClient } from './get-client.js'
2 |
3 | import type {
4 | Answer,
5 | CheckoutParams,
6 | CheckoutResponse,
7 | CurrentPhase,
8 | FeatureName,
9 | Features,
10 | Limits,
11 | LookupOrgResponse,
12 | Model,
13 | OrgInfo,
14 | OrgName,
15 | PaymentMethodsResponse,
16 | Phase,
17 | PushResponse,
18 | ReportParams,
19 | ScheduleParams,
20 | ScheduleResponse,
21 | SubscribeParams,
22 | TierGetClientOptions,
23 | TierWithClock,
24 | Usage,
25 | WhoAmIResponse,
26 | WhoIsResponse,
27 | } from './client.js'
28 | import {
29 | isErrorResponse,
30 | isFeatureName,
31 | isFeatureNameVersioned,
32 | isFeatures,
33 | isOrgName,
34 | isPhase,
35 | isPlanName,
36 | isTierError,
37 | Tier,
38 | validateFeatureDefinition,
39 | validateFeatureTier,
40 | validateModel,
41 | validatePlan,
42 | } from './client.js'
43 |
44 | // actual API methods
45 |
46 | /**
47 | * Pull the full {@link types.Model} pushed to Tier
48 | *
49 | * Convenience wrapper for {@link client.Tier.pull | Tier.pull}
50 | */
51 | export async function pull(
52 | clientOptions?: TierGetClientOptions
53 | ): Promise {
54 | const tier = await getClient(clientOptions)
55 | return tier.pull()
56 | }
57 |
58 | /**
59 | * Similar to {@link client.Tier.pull}, but filters plans to only include
60 | * the highest version of each plan. Plan versions are sorted numerically
61 | * if they are decimal integers, or lexically in the `en` locale otherwise.
62 | *
63 | * So, for example, the plan version `20test` will be considered "lower"
64 | * than `9test`, because the non-numeric string causes it to be lexically
65 | * sorted. But the plan version `20` sill be considered "higher" than the
66 | * plan version `9`, because both are strictly numeric.
67 | *
68 | * Convenience wrapper for {@link client.Tier.pullLatest | Tier.pullLatest}
69 | *
70 | * **Note** Plan versions are inherently arbitrary, and as such, they really
71 | * should not be sorted or given any special priority by being "latest".
72 | *
73 | * This method will be removed in version 6 of this SDK.
74 | *
75 | * @deprecated
76 | */
77 | export async function pullLatest(
78 | clientOptions?: TierGetClientOptions
79 | ): Promise {
80 | const tier = await getClient(clientOptions)
81 | return tier.pullLatest()
82 | }
83 |
84 | /**
85 | * Look up the limits for all features for a given {@link types.OrgName}
86 | *
87 | * Convenience wrapper for {@link client.Tier.lookupLimits | Tier.lookupLimits}
88 | */
89 | export async function lookupLimits(
90 | org: OrgName,
91 | clientOptions?: TierGetClientOptions
92 | ): Promise {
93 | const tier = await getClient(clientOptions)
94 | return tier.lookupLimits(org)
95 | }
96 |
97 | /**
98 | * Look up limits for a given {@link types.FeatureName} and {@link types.OrgName}
99 | *
100 | * Convenience wrapper for {@link client.Tier.lookupLimit | Tier.lookupLimit}
101 | */
102 | export async function lookupLimit(
103 | org: OrgName,
104 | feature: FeatureName,
105 | clientOptions?: TierGetClientOptions
106 | ): Promise {
107 | const tier = await getClient(clientOptions)
108 | return tier.lookupLimit(org, feature)
109 | }
110 |
111 | /**
112 | * Report metered feature usage
113 | *
114 | * Convenience wrapper for {@link client.Tier.report | Tier.report}
115 | */
116 | export async function report(
117 | org: OrgName,
118 | feature: FeatureName,
119 | n: number = 1,
120 | options?: ReportParams,
121 | clientOptions?: TierGetClientOptions
122 | ): Promise<{}> {
123 | const tier = await getClient(clientOptions)
124 | return await tier.report(org, feature, n, options)
125 | }
126 |
127 | /**
128 | * Return an {@link answer.Answer} indicating whether an org can
129 | * access a feature, or if they are at their plan limit.
130 | *
131 | * Convenience wrapper for {@link client.Tier.can | Tier.can}
132 | */
133 | export async function can(
134 | org: OrgName,
135 | feature: FeatureName,
136 | clientOptions?: TierGetClientOptions
137 | ): Promise {
138 | const tier = await getClient(clientOptions)
139 | return tier.can(org, feature)
140 | }
141 |
142 | /**
143 | * Generate a checkout URL to set an org's payment info, and optionally
144 | * to create a subscription on completion.
145 | *
146 | * `successUrl` param should be a URL within your application where the
147 | * user will be redirected upon completion.
148 | *
149 | * Convenience wrapper for {@link client.Tier.checkout | Tier.checkout}
150 | */
151 | export async function checkout(
152 | org: OrgName,
153 | successUrl: string,
154 | { cancelUrl, features, trialDays, tax }: CheckoutParams = {},
155 | clientOptions?: TierGetClientOptions
156 | ): Promise {
157 | const tier = await getClient(clientOptions)
158 | return await tier.checkout(org, successUrl, {
159 | cancelUrl,
160 | features,
161 | trialDays,
162 | tax,
163 | })
164 | }
165 |
166 | /**
167 | * Simple interface for creating a new phase in the org's subscription
168 | * schedule.
169 | *
170 | * Setting `trialDays` will cause it to prepend a "trial" phase on the
171 | * effective date, and delay the creation of the actual non-trial
172 | * subscription phase by the specified number of days.
173 | *
174 | * Convenience wrapper for {@link client.Tier.subscribe | Tier.subscribe}
175 | */
176 | export async function subscribe(
177 | org: OrgName,
178 | features: Features | Features[],
179 | { effective, info, trialDays }: SubscribeParams = {},
180 | clientOptions?: TierGetClientOptions
181 | ): Promise {
182 | const tier = await getClient(clientOptions)
183 | return await tier.subscribe(org, features, {
184 | effective,
185 | info,
186 | trialDays,
187 | })
188 | }
189 |
190 | /**
191 | * Cancel an org's subscriptions
192 | *
193 | * Convenience wrapper for {@link client.Tier.cancel | Tier.cancel}
194 | */
195 | export async function cancel(
196 | org: OrgName,
197 | clientOptions?: TierGetClientOptions
198 | ): Promise {
199 | const tier = await getClient(clientOptions)
200 | return await tier.cancel(org)
201 | }
202 |
203 | /**
204 | * Advanced interface for creating arbitrary schedule phases in any
205 | * order.
206 | *
207 | * Convenience wrapper for {@link client.Tier.schedule | Tier.schedule}
208 | */
209 | export async function schedule(
210 | org: OrgName,
211 | phases?: Phase[],
212 | { info }: ScheduleParams = {},
213 | clientOptions?: TierGetClientOptions
214 | ): Promise {
215 | const tier = await getClient(clientOptions)
216 | return await tier.schedule(org, phases, { info })
217 | }
218 |
219 | /**
220 | * Update an org's metadata. Note that any fields not set (other than
221 | * `metadata`) will be reset to empty `''` values on any update.
222 | *
223 | * Convenience wrapper for {@link client.Tier.updateOrg | Tier.updateOrg}
224 | */
225 | export async function updateOrg(
226 | org: OrgName,
227 | info: OrgInfo
228 | ): Promise {
229 | const tier = await getClient()
230 | return await tier.updateOrg(org, info)
231 | }
232 |
233 | /**
234 | * Get an org's billing provider identifier
235 | *
236 | * Convenience wrapper for {@link client.Tier.whois | Tier.whois}
237 | */
238 | export async function whois(org: OrgName): Promise {
239 | const tier = await getClient()
240 | return tier.whois(org)
241 | }
242 |
243 | /**
244 | * Look up all {@link types.OrgInfo} metadata about an org
245 | *
246 | * Convenience wrapper for {@link client.Tier.lookupOrg | Tier.lookupOrg}
247 | */
248 | export async function lookupOrg(org: OrgName): Promise {
249 | const tier = await getClient()
250 | return tier.lookupOrg(org)
251 | }
252 |
253 | /**
254 | * Look up all the Payment Methods available for a given {@link types.OrgName}
255 | *
256 | * Convenience wrapper for {@link client.Tier.lookupPaymentMethods | Tier.lookupPaymentMethods}
257 | */
258 | export async function lookupPaymentMethods(
259 | org: OrgName
260 | ): Promise {
261 | const tier = await getClient()
262 | return tier.lookupPaymentMethods(org)
263 | }
264 |
265 | /**
266 | * Get information about the current sidecare API in use
267 | *
268 | * Convenience wrapper for {@link client.Tier.whoami | Tier.whoami}
269 | */
270 | export async function whoami(): Promise {
271 | const tier = await getClient()
272 | return tier.whoami()
273 | }
274 |
275 | /**
276 | * Fetch the current phase for an org
277 | *
278 | * Convenience wrapper for {@link client.Tier.lookupPhase | Tier.lookupPhase}
279 | */
280 | export async function lookupPhase(org: OrgName): Promise {
281 | const tier = await getClient()
282 | return tier.lookupPhase(org)
283 | }
284 |
285 | /**
286 | * Push a new {@link types.Model} to Tier
287 | *
288 | * Any previously pushed {@link types.PlanName} will be ignored, new
289 | * plans will be added.
290 | *
291 | * Convenience wrapper for {@link client.Tier.push | Tier.push}
292 | */
293 | export async function push(model: Model): Promise {
294 | const tier = await getClient()
295 | return tier.push(model)
296 | }
297 |
298 | /**
299 | * Return a new Tier client that has a test clock
300 | */
301 | export async function withClock(
302 | name: string,
303 | start: Date = new Date(),
304 | options: TierGetClientOptions = {}
305 | ): Promise {
306 | const tier = await getClient(options)
307 | return tier.withClock(name, start)
308 | }
309 |
310 | /**
311 | * Get a client with the settings from the environment
312 | */
313 | export async function fromEnv(
314 | options: TierGetClientOptions = {}
315 | ): Promise {
316 | return getClient(options)
317 | }
318 |
319 | export * from './client.js'
320 | export type { TierGetClientOptions, TierWithClock } from './client.js'
321 | export type {
322 | Aggregate,
323 | CancelPhase,
324 | CheckoutParams,
325 | CheckoutResponse,
326 | CurrentPhase,
327 | CurrentPhaseResponse,
328 | Divide,
329 | FeatureDefinition,
330 | FeatureName,
331 | FeatureNameVersioned,
332 | Features,
333 | FeatureTier,
334 | Interval,
335 | Limits,
336 | LookupOrgResponse,
337 | Mode,
338 | Model,
339 | OrgInfo,
340 | OrgName,
341 | PaymentMethodsResponse,
342 | Phase,
343 | Plan,
344 | PlanName,
345 | PushResponse,
346 | ReportParams,
347 | ReportResponse,
348 | ScheduleParams,
349 | ScheduleResponse,
350 | SubscribeParams,
351 | Usage,
352 | WhoAmIResponse,
353 | WhoIsResponse,
354 | } from './tier-types.js'
355 |
356 | /* c8 ignore start */
357 | /**
358 | * @deprecated alias for lookupLimits
359 | */
360 | export async function limits(org: OrgName): Promise {
361 | const tier = await getClient()
362 | return tier.limits(org)
363 | }
364 | /**
365 | * @deprecated alias for lookupLimit
366 | */
367 | export async function limit(
368 | org: OrgName,
369 | feature: FeatureName
370 | ): Promise {
371 | const tier = await getClient()
372 | return tier.limit(org, feature)
373 | }
374 | /**
375 | * @deprecated alias for lookupPhase
376 | */
377 | export async function phase(org: OrgName): Promise {
378 | const tier = await getClient()
379 | return tier.phase(org)
380 | }
381 | /* c8 ignore stop */
382 |
383 | const TIER = {
384 | isErrorResponse,
385 | isFeatureName,
386 | isFeatureNameVersioned,
387 | isFeatures,
388 | isOrgName,
389 | isPhase,
390 | isPlanName,
391 | isTierError,
392 | validateFeatureDefinition,
393 | validateFeatureTier,
394 | validateModel,
395 | validatePlan,
396 |
397 | Tier,
398 |
399 | can,
400 | cancel,
401 | checkout,
402 | lookupLimit,
403 | lookupLimits,
404 | lookupOrg,
405 | lookupPaymentMethods,
406 | lookupPhase,
407 | pull,
408 | push,
409 | report,
410 | schedule,
411 | subscribe,
412 | updateOrg,
413 | whoami,
414 | whois,
415 |
416 | withClock,
417 | fromEnv,
418 |
419 | pullLatest,
420 | limit,
421 | limits,
422 | phase,
423 | }
424 |
425 | export default TIER
426 |
--------------------------------------------------------------------------------
/src/is.ts:
--------------------------------------------------------------------------------
1 | // a handful of helpful utilities for testing what things are.
2 |
3 | // just keeping the double-negative in one place, since I so often
4 | // type this wrong and get annoyed.
5 | export const isVArray = (arr: any, valTest: (v: any) => boolean) =>
6 | Array.isArray(arr) && !arr.some(v => !valTest(v))
7 |
8 | export const isDate = (d: any): d is Date => isObj(d) && d instanceof Date
9 |
10 | export const isObj = (c: any): c is { [k: string]: any } =>
11 | !!c && typeof c === 'object'
12 |
13 | export const optionalType = (c: any, t: string): boolean =>
14 | c === undefined || typeof c === t
15 |
16 | export const optionalString = (c: any): c is string | undefined =>
17 | optionalType(c, 'string')
18 |
19 | export const optionalKV = (
20 | c: any,
21 | keyTest: (k: string) => boolean,
22 | valTest: (v: any) => boolean
23 | ): boolean => c === undefined || isKV(c, keyTest, valTest)
24 |
25 | export const optionalIs = (c: any, test: (c: any) => boolean) =>
26 | c === undefined || test(c)
27 |
28 | export const optionalIsVArray = (c: any, valTest: (v: any) => boolean) =>
29 | c === undefined || isVArray(c, valTest)
30 |
31 | export const isKV = (
32 | obj: any,
33 | keyTest: (k: string) => boolean,
34 | valTest: (v: any) => boolean
35 | ): boolean =>
36 | isObj(obj) &&
37 | isVArray(Object.keys(obj), keyTest) &&
38 | isVArray(Object.values(obj), valTest)
39 |
40 | export const unexpectedFields = (
41 | obj: { [k: string]: any },
42 | ...keys: string[]
43 | ): string[] => {
44 | const expect = new Set(keys)
45 | return Object.keys(obj).filter(k => !expect.has(k))
46 | }
47 |
48 | export const hasOnly = (obj: any, ...keys: string[]): boolean => {
49 | if (!isObj(obj)) {
50 | return false
51 | }
52 | const expect = new Set(keys)
53 | for (const k of Object.keys(obj)) {
54 | if (!expect.has(k)) {
55 | return false
56 | }
57 | }
58 | return true
59 | }
60 |
61 | export const isPosInt = (n: any): boolean => Number.isInteger(n) && n > 0
62 | export const isNonNegInt = (n: any): boolean => Number.isInteger(n) && n >= 0
63 | export const isNonNegNum = (n: any): boolean =>
64 | typeof n === 'number' && isFinite(n) && n >= 0
65 |
--------------------------------------------------------------------------------
/src/tier-error.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module tiererror
3 | */
4 | import { isObj } from './is.js'
5 | /**
6 | * Test whether a value is a valid {@link TierError}
7 | */
8 | export const isTierError = (e: any): e is TierError =>
9 | isObj(e) && e instanceof TierError
10 | /**
11 | * Error subclass raised for any error returned by the API.
12 | * Should not be instantiated directly.
13 | */
14 | export class TierError extends Error {
15 | /**
16 | * The API endpoint that was requested
17 | */
18 | public path: string
19 | /**
20 | * The data that was sent to the API endpoint. Will be a parsed
21 | * JavaScript object unless the request JSON was invalid, in which
22 | * case it will be a string.
23 | */
24 | public requestData: any
25 | /**
26 | * The HTTP response status code returned
27 | */
28 | public status: number
29 | /**
30 | * The `code` field in the {@link ErrorResponse}
31 | */
32 | public code?: string
33 | /**
34 | * The HTTP response body. Will be a parsed JavaScript object
35 | * unless the response JSON was invalid, in which case it will
36 | * be a string.
37 | */
38 | public responseData: any
39 |
40 | /**
41 | * An underlying system error or other cause.
42 | */
43 | public cause?: Error
44 |
45 | constructor(
46 | path: string,
47 | reqBody: any,
48 | status: number,
49 | resBody: any,
50 | er?: any
51 | ) {
52 | if (isErrorResponse(resBody)) {
53 | super(resBody.message)
54 | this.code = resBody.code
55 | } else {
56 | super('Tier request failed')
57 | }
58 | if (er && typeof er === 'object' && er instanceof Error) {
59 | this.cause = er
60 | }
61 | this.path = path
62 | this.requestData = reqBody
63 | this.status = status
64 | this.responseData = resBody
65 | }
66 | }
67 |
68 | /**
69 | * Response returned by the Tier API on failure
70 | * @internal
71 | */
72 | export interface ErrorResponse {
73 | status: number
74 | code: string
75 | message: string
76 | }
77 | /**
78 | * Test whether a value is a valid {@link ErrorResponse}
79 | * @internal
80 | */
81 | export const isErrorResponse = (e: any): e is ErrorResponse =>
82 | isObj(e) &&
83 | typeof e.status === 'number' &&
84 | typeof e.message === 'string' &&
85 | typeof e.code === 'string'
86 |
--------------------------------------------------------------------------------
/src/tier-types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module types
3 | */
4 | import {
5 | hasOnly,
6 | isDate,
7 | isKV,
8 | isNonNegInt,
9 | isNonNegNum,
10 | isObj,
11 | isPosInt,
12 | isVArray,
13 | optionalIs,
14 | optionalIsVArray,
15 | optionalKV,
16 | optionalString,
17 | optionalType,
18 | unexpectedFields,
19 | } from './is.js'
20 |
21 | export interface ClockRequest {
22 | id?: string
23 | name?: string
24 | present: Date | string
25 | }
26 |
27 | export interface ClockResponse {
28 | id: string
29 | link: string
30 | present: string
31 | status: string
32 | }
33 |
34 | /**
35 | * The name of an organization, used to uniquely reference a
36 | * customer account within Tier. Any unique string identifier,
37 | * prefixed with 'org:'
38 | */
39 | export type OrgName = `org:${string}`
40 |
41 | /**
42 | * Test whether a value is a valid {@link OrgName}
43 | */
44 | export const isOrgName = (o: any): o is OrgName =>
45 | typeof o === 'string' && o.startsWith('org:') && o !== 'org:'
46 |
47 | /**
48 | * The name of a feature within Tier. Can be any string
49 | * containing ASCII alphanumeric characters and ':'
50 | */
51 | export type FeatureName = `feature:${string}`
52 |
53 | /**
54 | * Test whether a value is a valid {@link FeatureName}
55 | */
56 | export const isFeatureName = (f: any): f is FeatureName =>
57 | typeof f === 'string' && /^feature:[a-zA-Z0-9:]+$/.test(f)
58 |
59 | /**
60 | * A Tier pricing model, as would be stored within a `pricing.json`
61 | * file, or created on
62 | */
63 | export interface Model {
64 | plans: {
65 | [p: PlanName]: Plan
66 | }
67 | }
68 |
69 | /**
70 | * Test whether a value is a valid {@link Model}
71 | */
72 | export const isModel = (m: any): m is Model =>
73 | hasOnly(m, 'plans') && isKV(m.plans, isPlanName, isPlan)
74 |
75 | /**
76 | * Asserts that a value is a valid {@link Model}
77 | *
78 | * If it is not, then a string is thrown indicating the problem.
79 | */
80 | export const validateModel = (m: any): asserts m is Model => {
81 | if (!isObj(m)) {
82 | throw 'not an object'
83 | }
84 | if (!isObj(m.plans)) {
85 | throw 'missing or invalid plans, must be object'
86 | }
87 | for (const [pn, plan] of Object.entries(m.plans)) {
88 | if (!isPlanName(pn)) {
89 | throw `invalid plan name: ${pn}`
90 | }
91 | try {
92 | validatePlan(plan as any)
93 | } catch (er) {
94 | throw `plans['${pn}']: ${er}`
95 | }
96 | }
97 | const unexpected = unexpectedFields(m, 'plans')
98 | if (unexpected.length !== 0) {
99 | throw `unexpected field(s): ${unexpected.join(', ')}`
100 | }
101 | }
102 |
103 | /**
104 | * The definition of a plan within a {@link Model}.
105 | */
106 | export interface Plan {
107 | title?: string
108 | features?: {
109 | [f: FeatureName]: FeatureDefinition
110 | }
111 | currency?: string
112 | interval?: Interval
113 | }
114 |
115 | /**
116 | * Test whether a value is a valid {@link Plan}
117 | */
118 | export const isPlan = (p: any): p is Plan =>
119 | isObj(p) &&
120 | hasOnly(p, 'title', 'currency', 'interval', 'features') &&
121 | optionalString(p.title) &&
122 | optionalKV(p.features, isFeatureName, isFeatureDefinition) &&
123 | optionalIs(p.currency, isCurrency) &&
124 | optionalIs(p.interval, isInterval)
125 |
126 | const isCurrency = (c: any): c is Plan['currency'] =>
127 | typeof c === 'string' && c.length === 3 && c === c.toLowerCase()
128 |
129 | /**
130 | * Asserts that a value is a valid {@link Plan}
131 | *
132 | * If not, throws a string indicating the source of the problem.
133 | */
134 | export const validatePlan: (p: any) => void = (p: any): asserts p is Plan => {
135 | if (!isObj(p)) {
136 | throw 'not an object'
137 | }
138 | if (p.title !== undefined && typeof p.title !== 'string') {
139 | throw 'invalid title, must be string'
140 | }
141 | if (p.features !== undefined) {
142 | if (!isObj(p.features)) {
143 | throw 'invalid features field, must be object'
144 | }
145 | for (const [fn, fdef] of Object.entries(p.features)) {
146 | if (!isFeatureName(fn)) {
147 | throw `invalid feature name: ${fn}`
148 | }
149 | try {
150 | validateFeatureDefinition(fdef)
151 | } catch (er) {
152 | throw `features['${fn}']: ${er}`
153 | }
154 | }
155 | }
156 | if (!optionalIs(p.currency, isCurrency)) {
157 | throw `invalid currency: ${p.currency}`
158 | }
159 | if (!optionalIs(p.interval, isInterval)) {
160 | throw `invalid interval: ${p.interval}`
161 | }
162 | const unexpected = unexpectedFields(
163 | p,
164 | 'title',
165 | 'currency',
166 | 'interval',
167 | 'features'
168 | )
169 | if (unexpected.length !== 0) {
170 | throw `unexpected field(s): ${unexpected.join(', ')}`
171 | }
172 | }
173 |
174 | /**
175 | * Valid values for the `interval` field in a {@link FeatureDefinition}
176 | */
177 | export type Interval = '@daily' | '@weekly' | '@monthly' | '@yearly'
178 | /**
179 | * Test whether a value is a valid {@link Interval}
180 | */
181 | export const isInterval = (i: any): i is Interval =>
182 | i === '@daily' || i === '@weekly' || i === '@monthly' || i === '@yearly'
183 |
184 | /**
185 | * {@link FeatureDefinition} transforms.
186 | */
187 | export interface Divide {
188 | by?: number
189 | rounding?: 'up'
190 | }
191 |
192 | /**
193 | * Test whether a `divide` field is a valid transform config
194 | */
195 | export const isDivide = (a: any): a is Divide =>
196 | !!a &&
197 | typeof a === 'object' &&
198 | hasOnly(a, 'by', 'rounding') &&
199 | optionalIs(a.by, isNonNegInt) &&
200 | optionalIs(a.rounding, (r: any) => r === 'up')
201 |
202 | export const validateDivide: (a: any) => void = (
203 | a: any
204 | ): asserts a is Divide => {
205 | if (!a || typeof a !== 'object') {
206 | throw 'not an object'
207 | }
208 | if (!optionalIs(a.by, isNonNegInt)) {
209 | throw 'by must be a non-negative integer'
210 | }
211 | if (!optionalIs(a.rounding, r => r === 'up')) {
212 | throw 'rounding must be "up" if set ("down" is default)'
213 | }
214 | }
215 |
216 | /**
217 | * The definition of a feature within a {@link Plan}.
218 | */
219 | export interface FeatureDefinition {
220 | title?: string
221 | base?: number
222 | tiers?: FeatureTier[]
223 | mode?: Mode
224 | aggregate?: Aggregate
225 | divide?: Divide
226 | }
227 | /**
228 | * Valid values for the `aggregate` field in a {@link FeatureDefinition}
229 | */
230 | export type Aggregate = 'sum' | 'max' | 'last' | 'perpetual'
231 | /**
232 | * Test whether a value is a valid {@link Aggregate}
233 | */
234 | export const isAggregate = (a: any): a is Aggregate =>
235 | a === 'sum' || a === 'max' || a === 'last' || a === 'perpetual'
236 |
237 | /**
238 | * Test whether a value is a valid {@link FeatureDefinition}
239 | */
240 | export const isFeatureDefinition = (f: any): f is FeatureDefinition =>
241 | hasOnly(f, 'base', 'tiers', 'mode', 'aggregate', 'title', 'divide') &&
242 | optionalString(f.title) &&
243 | optionalIs(f.base, isNonNegNum) &&
244 | optionalIs(f.mode, isMode) &&
245 | optionalIsVArray(f.tiers, isFeatureTier) &&
246 | !(f.base !== undefined && f.tiers) &&
247 | optionalIs(f.aggregate, isAggregate) &&
248 | optionalIs(f.divide, isDivide) &&
249 | !(f.divide?.by && (f.tiers?.length > 1 || f.tiers?.[0]?.base))
250 |
251 | /**
252 | * Asserts that a value is a valid {@link FeatureDefinition}
253 | *
254 | * If not, a string is thrown indicating the source of the problem.
255 | */
256 | export const validateFeatureDefinition: (f: any) => void = (
257 | f: any
258 | ): asserts f is FeatureDefinition => {
259 | if (!isObj(f)) {
260 | throw 'not an object'
261 | }
262 | if (!optionalString(f.title)) {
263 | throw 'title not a string'
264 | }
265 | if (!optionalIs(f.base, isNonNegInt)) {
266 | throw 'invalid base, must be non-negative number'
267 | }
268 | if (!optionalIs(f.mode, isMode)) {
269 | throw 'invalid mode'
270 | }
271 | if (f.tiers && f.base !== undefined) {
272 | throw 'tiers and base cannot be set together'
273 | }
274 | // unroll this so we can show the tier that failed
275 | if (f.tiers !== undefined) {
276 | if (!Array.isArray(f.tiers)) {
277 | throw 'non-array tiers field'
278 | }
279 | f.tiers.forEach((t: FeatureTier, i: number) => {
280 | try {
281 | validateFeatureTier(t)
282 | } catch (er) {
283 | throw `tiers[${i}]: ${er}`
284 | }
285 | })
286 | }
287 | if (!optionalIs(f.aggregate, isAggregate)) {
288 | throw 'invalid aggregate'
289 | }
290 | if (f.divide !== undefined) {
291 | if (
292 | f.tiers &&
293 | (f.tiers.length > 1 || (f.tiers.length === 1 && f.tiers[0].base)) &&
294 | f.divide.by
295 | ) {
296 | throw 'may not use divide.by with multiple tiers or tier base price'
297 | }
298 | try {
299 | validateDivide(f.divide)
300 | } catch (er) {
301 | throw `divide: ${er}`
302 | }
303 | }
304 | const unexpected = unexpectedFields(
305 | f,
306 | 'base',
307 | 'tiers',
308 | 'mode',
309 | 'aggregate',
310 | 'title',
311 | 'divide'
312 | )
313 | if (unexpected.length) {
314 | throw `unexpected field(s): ${unexpected.join(', ')}`
315 | }
316 | }
317 |
318 | /**
319 | * Valid values for the `mode` field in a {@link FeatureDefinition}
320 | */
321 | export type Mode = 'graduated' | 'volume'
322 | /**
323 | * Test whether a value is a valiid {@link Mode}
324 | */
325 | export const isMode = (m: any): m is Mode => m === 'graduated' || m === 'volume'
326 |
327 | /**
328 | * Entry in the {@link FeatureDefinition} `tier` array
329 | */
330 | export interface FeatureTier {
331 | upto?: number
332 | price?: number
333 | base?: number
334 | }
335 |
336 | /**
337 | * Test whether a value is a valid {@link FeatureTier}
338 | */
339 | export const isFeatureTier = (t: any): t is FeatureTier =>
340 | hasOnly(t, 'upto', 'price', 'base') &&
341 | optionalIs(t.upto, isPosInt) &&
342 | optionalIs(t.price, isNonNegNum) &&
343 | optionalIs(t.base, isNonNegInt)
344 |
345 | /**
346 | * Validate that a value is a valid {@link FeatureTier}
347 | *
348 | * If not, a string is thrown indicating the source of the problem.
349 | */
350 | export const validateFeatureTier: (t: any) => void = (
351 | t: any
352 | ): asserts t is FeatureTier => {
353 | if (!isObj(t)) {
354 | throw 'not an object'
355 | }
356 | if (!optionalIs(t.upto, isPosInt)) {
357 | throw 'invalid upto, must be integer greater than 0'
358 | }
359 | if (!optionalIs(t.price, isNonNegNum)) {
360 | throw 'invalid price, must be non-negative number'
361 | }
362 | if (!optionalIs(t.base, isNonNegInt)) {
363 | throw 'invalid base, must be non-negative integer'
364 | }
365 | const unexpected = unexpectedFields(t, 'base', 'price', 'upto')
366 | if (unexpected.length !== 0) {
367 | throw `unexpected field(s): ${unexpected.join(', ')}`
368 | }
369 | }
370 |
371 | /**
372 | * Object representing some amount of feature consumption.
373 | */
374 | export interface Usage {
375 | feature: FeatureName
376 | used: number
377 | limit: number
378 | }
379 |
380 | /**
381 | * The set of {@link Usage} values for each feature that an
382 | * org has access to.
383 | */
384 | export interface Limits {
385 | org: OrgName
386 | usage: Usage[]
387 | }
388 |
389 | /**
390 | * A {@link Plan} identifier. Format is `plan:@`.
391 | * Name can contain any ASCII alphanumeric characters and `:`.
392 | * Version can contain any ASCII alphanumeric characters.
393 | */
394 | export type PlanName = `plan:${string}@${string}`
395 | /**
396 | * An identifier for a feature as defined within a given plan.
397 | * Format is `@` where `feature` is a {@link FeatureName}
398 | * and `plan` is a {@link PlanName}.
399 | *
400 | * FeatureNameVersioned and {@link PlanName} strings may be used
401 | * equivalently to specify prices and entitlements to Tier methods.
402 | */
403 | export type FeatureNameVersioned = `${FeatureName}@${PlanName}`
404 | /**
405 | * alias for {@link FeatureNameVersioned}
406 | * @deprecated
407 | */
408 | export type VersionedFeatureName = FeatureNameVersioned
409 | /**
410 | * Either a {@link PlanName} or {@link FeatureNameVersioned}
411 | *
412 | * The type of values that may be used to specify prices and entitlements.
413 | */
414 | export type Features = PlanName | FeatureNameVersioned
415 |
416 | /**
417 | * Test whether a value is a valid {@link PlanName}
418 | */
419 | export const isPlanName = (p: any): p is PlanName =>
420 | typeof p === 'string' && /^plan:[a-zA-Z0-9:]+@[a-zA-Z0-9]+$/.test(p)
421 |
422 | /**
423 | * Test whether a value is a valid {@link FeatureNameVersioned}
424 | */
425 | export const isFeatureNameVersioned = (f: any): f is FeatureNameVersioned =>
426 | typeof f === 'string' &&
427 | /^feature:[a-zA-Z0-9:]+@plan:[a-zA-Z0-9:]+@[a-zA-Z0-9]+$/.test(f)
428 | /**
429 | * @deprecated alias for {@link isFeatureNameVersioned}
430 | */
431 | export const isVersionedFeatureName = isFeatureNameVersioned
432 |
433 | /**
434 | * Test whether a value is a valid {@link Features}
435 | */
436 | export const isFeatures = (f: any): f is Features =>
437 | isPlanName(f) || isFeatureNameVersioned(f)
438 |
439 | /**
440 | * Object representing the current phase in an org's subscription schedule
441 | */
442 | export interface CurrentPhase {
443 | /**
444 | * When the current phase became effective
445 | */
446 | effective: Date
447 | /**
448 | * When the current phase will end (if defined)
449 | */
450 | end?: Date
451 | /**
452 | * The set of versioned features the customer is subscribed to in this phase
453 | */
454 | features?: FeatureNameVersioned[]
455 | /**
456 | * The set of plans the customer is currently subscribed to in this phase
457 | */
458 | plans?: PlanName[]
459 | /**
460 | * One-off versioned features that the customer is subscribed to
461 | */
462 | fragments?: FeatureNameVersioned[]
463 | /**
464 | * Whether or not this is a trial phase
465 | */
466 | trial: boolean
467 | /**
468 | * The effective and end date of the current billing period
469 | *
470 | * For trial periods, this will be the start and end of the trial phase,
471 | * no matter how long it is.
472 | */
473 | current?: {
474 | effective: Date
475 | end: Date
476 | }
477 | }
478 |
479 | export interface CurrentPhaseResponse {
480 | effective: string
481 | end?: string
482 | features?: FeatureNameVersioned[]
483 | plans?: PlanName[]
484 | fragments?: FeatureNameVersioned[]
485 | trial?: boolean
486 | current: {
487 | effective: string
488 | end: string
489 | }
490 | }
491 |
492 | /**
493 | * Object representing a phase in an org's subscription schedule, for
494 | * creating new schedules via `tier.schedule()`.
495 | */
496 | export interface Phase {
497 | effective?: Date
498 | features: Features[]
499 | trial?: boolean
500 | }
501 |
502 | /**
503 | * Special empty {@link Phase} object that has no features, indicating
504 | * that the org's plan should be terminated.
505 | */
506 | export interface CancelPhase {}
507 |
508 | /**
509 | * Test whether a value is a valid {@link Phase}
510 | */
511 | export const isPhase = (p: any): p is Phase =>
512 | isObj(p) &&
513 | optionalIs(p.effective, isDate) &&
514 | optionalType(p.trial, 'boolean') &&
515 | isVArray(p.features, isFeatures)
516 |
517 | /**
518 | * Options for the {@link client.Tier.checkout} method
519 | */
520 | export interface CheckoutParams {
521 | cancelUrl?: string
522 | features?: Features | Features[]
523 | trialDays?: number
524 | requireBillingAddress?: boolean
525 | tax?: {
526 | automatic?: boolean
527 | collectId?: boolean
528 | }
529 | }
530 |
531 | export interface CheckoutRequest {
532 | org: OrgName
533 | success_url: string
534 | features?: Features[]
535 | trial_days?: number
536 | cancel_url?: string
537 | require_billing_address?: boolean
538 | tax?: {
539 | automatic?: boolean
540 | collect_id?: boolean
541 | }
542 | }
543 |
544 | /**
545 | * Response from the {@link client.Tier.checkout} method, indicating the url
546 | * that the user must visit to complete the checkout process.
547 | */
548 | export interface CheckoutResponse {
549 | url: string
550 | }
551 |
552 | export interface ScheduleRequest {
553 | org: OrgName
554 | phases?: Phase[] | [CancelPhase]
555 | info?: OrgInfoJSON
556 | payment_method_id?: string
557 | }
558 |
559 | /**
560 | * Response from the methods that use the `/v1/subscribe` endpoint.
561 | */
562 | export interface ScheduleResponse {}
563 |
564 | /**
565 | * Options for the {@link client.Tier.subscribe} method
566 | */
567 | export interface SubscribeParams {
568 | effective?: Date
569 | info?: OrgInfo
570 | trialDays?: number
571 | paymentMethodID?: string
572 | }
573 |
574 | /**
575 | * Options for the {@link client.Tier.schedule} method
576 | */
577 | export interface ScheduleParams {
578 | info?: OrgInfo
579 | paymentMethodID?: string
580 | }
581 |
582 | export interface PaymentMethodsResponseJSON {
583 | org: string
584 | methods: null | string[]
585 | }
586 |
587 | /**
588 | * Response from the {@link client.Tier.lookupPaymentMethods} method
589 | */
590 | export interface PaymentMethodsResponse {
591 | org: string
592 | methods: string[]
593 | }
594 |
595 | /**
596 | * Options for the {@link client.Tier.report} and {@link answer.Answer.report} methods
597 | */
598 | export interface ReportParams {
599 | at?: Date
600 | clobber?: boolean
601 | }
602 |
603 | export interface ReportRequest {
604 | org: OrgName
605 | feature: FeatureName
606 | n?: number
607 | at?: Date
608 | clobber?: boolean
609 | }
610 |
611 | export interface ReportResponse {}
612 |
613 | /**
614 | * Response from the {@link client.Tier.whois} method
615 | */
616 | export interface WhoIsResponse {
617 | org: OrgName
618 | stripe_id: string
619 | }
620 |
621 | /**
622 | * The object shape we send/receive from the API itself.
623 | * Converted between this and OrgInfo when talking to the API,
624 | * to avoid the excess of snake case.
625 | */
626 | export interface OrgInfoJSON {
627 | email: string
628 | name: string
629 | description: string
630 | phone: string
631 | invoice_settings: {
632 | default_payment_method: string
633 | }
634 | metadata: { [key: string]: string }
635 | }
636 |
637 | /**
638 | * Object representing an org's billing metadata. Note that any fields
639 | * not set (other than `metadata`) will be reset to empty `''` values
640 | * on any update.
641 | *
642 | * Used by {@link client.Tier.lookupOrg}, {@link client.Tier.schedule}, and
643 | * {@link client.Tier.subscribe} methods.
644 | */
645 | export interface OrgInfo {
646 | email: string
647 | name: string
648 | description: string
649 | phone: string
650 | invoiceSettings?: {
651 | defaultPaymentMethod?: string
652 | }
653 | metadata: { [key: string]: string }
654 | }
655 |
656 | /**
657 | * Response from the {@link client.Tier.lookupOrg} method
658 | */
659 | export type LookupOrgResponse = WhoIsResponse & OrgInfo
660 |
661 | /**
662 | * Raw JSON response from the lookupOrg API route
663 | */
664 | export type LookupOrgResponseJSON = WhoIsResponse & OrgInfoJSON
665 |
666 | /**
667 | * Object indicating the success status of a given feature and plan
668 | * when using {@link client.Tier.push}
669 | */
670 | export interface PushResult {
671 | feature: FeatureNameVersioned
672 | status: string
673 | reason: string
674 | }
675 |
676 | /**
677 | * Response from the {@link client.Tier.push} method
678 | */
679 | export interface PushResponse {
680 | results?: PushResult[]
681 | }
682 |
683 | /**
684 | * Response from the {@link client.Tier.whoami} method
685 | */
686 | export interface WhoAmIResponse {
687 | id: string
688 | email: string
689 | key_source: string
690 | isolated: boolean
691 | url: string
692 | }
693 |
--------------------------------------------------------------------------------
/src/version.ts:
--------------------------------------------------------------------------------
1 | // this file gets overwritten in the build process with
2 | // the current package and git versions
3 | // Just here for convenience in development.
4 | export const version: string = '0.0.0-dev'
5 | export const git: string = '0000000000000000000000000000000000000000'
6 |
--------------------------------------------------------------------------------
/tap-snapshots/test/is.ts.test.cjs:
--------------------------------------------------------------------------------
1 | /* IMPORTANT
2 | * This snapshot file is auto-generated, but designed for humans.
3 | * It should be checked into source control and tracked carefully.
4 | * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5 | * Make sure to inspect the output below. Do not ignore changes!
6 | */
7 | 'use strict'
8 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 1`] = `
9 | Array [
10 | "not an object",
11 | null,
12 | ]
13 | `
14 |
15 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 10`] = `
16 | Array [
17 | undefined,
18 | Object {
19 | "divide": Object {
20 | "by": 100,
21 | "rounding": "up",
22 | },
23 | "tiers": Array [],
24 | },
25 | ]
26 | `
27 |
28 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 11`] = `
29 | Array [
30 | undefined,
31 | Object {
32 | "divide": Object {
33 | "by": 100,
34 | "rounding": "up",
35 | },
36 | "tiers": Array [
37 | Object {
38 | "price": 1,
39 | "upto": 1,
40 | },
41 | ],
42 | },
43 | ]
44 | `
45 |
46 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 12`] = `
47 | Array [
48 | "may not use divide.by with multiple tiers or tier base price",
49 | Object {
50 | "divide": Object {
51 | "by": 100,
52 | "rounding": "up",
53 | },
54 | "tiers": Array [
55 | Object {
56 | "price": 1,
57 | "upto": 1,
58 | },
59 | Object {},
60 | ],
61 | },
62 | ]
63 | `
64 |
65 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 13`] = `
66 | Array [
67 | undefined,
68 | Object {
69 | "tiers": Array [],
70 | },
71 | ]
72 | `
73 |
74 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 14`] = `
75 | Array [
76 | undefined,
77 | Object {
78 | "aggregate": "sum",
79 | "base": 1,
80 | "title": "x",
81 | },
82 | ]
83 | `
84 |
85 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 15`] = `
86 | Array [
87 | "title not a string",
88 | Object {
89 | "title": Object {
90 | "not": "a string",
91 | },
92 | },
93 | ]
94 | `
95 |
96 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 16`] = `
97 | Array [
98 | "invalid base, must be non-negative number",
99 | Object {
100 | "base": 1.2,
101 | },
102 | ]
103 | `
104 |
105 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 17`] = `
106 | Array [
107 | "invalid base, must be non-negative number",
108 | Object {
109 | "base": -1,
110 | },
111 | ]
112 | `
113 |
114 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 18`] = `
115 | Array [
116 | undefined,
117 | Object {
118 | "base": 0,
119 | },
120 | ]
121 | `
122 |
123 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 19`] = `
124 | Array [
125 | undefined,
126 | Object {
127 | "mode": "graduated",
128 | "tiers": Array [
129 | Object {},
130 | ],
131 | "title": "x",
132 | },
133 | ]
134 | `
135 |
136 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 2`] = `
137 | Array [
138 | "not an object",
139 | true,
140 | ]
141 | `
142 |
143 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 20`] = `
144 | Array [
145 | "tiers and base cannot be set together",
146 | Object {
147 | "base": 1,
148 | "tiers": Array [],
149 | "title": "x",
150 | },
151 | ]
152 | `
153 |
154 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 21`] = `
155 | Array [
156 | "invalid mode",
157 | Object {
158 | "mode": "not a valid mode",
159 | },
160 | ]
161 | `
162 |
163 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 22`] = `
164 | Array [
165 | "non-array tiers field",
166 | Object {
167 | "tiers": "tiers not an array",
168 | },
169 | ]
170 | `
171 |
172 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 23`] = `
173 | Array [
174 | "tiers[0]: invalid base, must be non-negative integer",
175 | Object {
176 | "tiers": Array [
177 | Object {
178 | "base": "tier invalid",
179 | },
180 | ],
181 | },
182 | ]
183 | `
184 |
185 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 24`] = `
186 | Array [
187 | "invalid aggregate",
188 | Object {
189 | "aggregate": "yolo",
190 | "base": 123,
191 | },
192 | ]
193 | `
194 |
195 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 25`] = `
196 | Array [
197 | "unexpected field(s): heloo",
198 | Object {
199 | "heloo": "world",
200 | },
201 | ]
202 | `
203 |
204 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 26`] = `
205 | Array [
206 | "non-array tiers field",
207 | Object {
208 | "tiers": Object {
209 | "not": "an array",
210 | },
211 | },
212 | ]
213 | `
214 |
215 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 27`] = `
216 | Array [
217 | "tiers[1]: unexpected field(s): x",
218 | Object {
219 | "tiers": Array [
220 | Object {},
221 | Object {
222 | "x": 1,
223 | },
224 | ],
225 | },
226 | ]
227 | `
228 |
229 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 28`] = `
230 | Array [
231 | "invalid base, must be non-negative number",
232 | Object {
233 | "base": 1.2,
234 | },
235 | ]
236 | `
237 |
238 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 3`] = `
239 | Array [
240 | undefined,
241 | Object {},
242 | ]
243 | `
244 |
245 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 4`] = `
246 | Array [
247 | "divide: not an object",
248 | Object {
249 | "base": 100,
250 | "divide": true,
251 | },
252 | ]
253 | `
254 |
255 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 5`] = `
256 | Array [
257 | undefined,
258 | Object {
259 | "base": 100,
260 | "divide": Object {
261 | "by": 100,
262 | },
263 | },
264 | ]
265 | `
266 |
267 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 6`] = `
268 | Array [
269 | "divide: by must be a non-negative integer",
270 | Object {
271 | "base": 100,
272 | "divide": Object {
273 | "by": 100.123,
274 | },
275 | },
276 | ]
277 | `
278 |
279 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 7`] = `
280 | Array [
281 | undefined,
282 | Object {
283 | "base": 100,
284 | "divide": Object {
285 | "by": 100,
286 | "rounding": "up",
287 | },
288 | },
289 | ]
290 | `
291 |
292 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 8`] = `
293 | Array [
294 | "divide: rounding must be \\"up\\" if set (\\"down\\" is default)",
295 | Object {
296 | "base": 100,
297 | "divide": Object {
298 | "by": 100,
299 | "rounding": "circle",
300 | },
301 | },
302 | ]
303 | `
304 |
305 | exports[`test/is.ts TAP validateFeatureDefinition > must match snapshot 9`] = `
306 | Array [
307 | "divide: rounding must be \\"up\\" if set (\\"down\\" is default)",
308 | Object {
309 | "base": 100,
310 | "divide": Object {
311 | "rounding": "circle",
312 | },
313 | },
314 | ]
315 | `
316 |
317 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 1`] = `
318 | Array [
319 | undefined,
320 | Object {},
321 | ]
322 | `
323 |
324 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 10`] = `
325 | Array [
326 | "invalid upto, must be integer greater than 0",
327 | Object {
328 | "upto": 0,
329 | },
330 | ]
331 | `
332 |
333 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 11`] = `
334 | Array [
335 | "not an object",
336 | null,
337 | ]
338 | `
339 |
340 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 12`] = `
341 | Array [
342 | "not an object",
343 | true,
344 | ]
345 | `
346 |
347 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 13`] = `
348 | Array [
349 | "invalid base, must be non-negative integer",
350 | Object {
351 | "base": "hello",
352 | },
353 | ]
354 | `
355 |
356 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 14`] = `
357 | Array [
358 | "invalid price, must be non-negative number",
359 | Object {
360 | "price": "hello",
361 | },
362 | ]
363 | `
364 |
365 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 15`] = `
366 | Array [
367 | undefined,
368 | Object {
369 | "price": 1.2,
370 | },
371 | ]
372 | `
373 |
374 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 16`] = `
375 | Array [
376 | "invalid upto, must be integer greater than 0",
377 | Object {
378 | "upto": "hello",
379 | },
380 | ]
381 | `
382 |
383 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 17`] = `
384 | Array [
385 | "invalid upto, must be integer greater than 0",
386 | Object {
387 | "upto": 1.2,
388 | },
389 | ]
390 | `
391 |
392 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 18`] = `
393 | Array [
394 | "invalid base, must be non-negative integer",
395 | Object {
396 | "base": -1.2,
397 | },
398 | ]
399 | `
400 |
401 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 19`] = `
402 | Array [
403 | "unexpected field(s): other",
404 | Object {
405 | "other": "thing",
406 | },
407 | ]
408 | `
409 |
410 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 2`] = `
411 | Array [
412 | undefined,
413 | Object {
414 | "base": 123,
415 | },
416 | ]
417 | `
418 |
419 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 3`] = `
420 | Array [
421 | "invalid base, must be non-negative integer",
422 | Object {
423 | "base": 1.3,
424 | },
425 | ]
426 | `
427 |
428 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 4`] = `
429 | Array [
430 | "invalid base, must be non-negative integer",
431 | Object {
432 | "base": -1,
433 | },
434 | ]
435 | `
436 |
437 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 5`] = `
438 | Array [
439 | undefined,
440 | Object {
441 | "base": 0,
442 | },
443 | ]
444 | `
445 |
446 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 6`] = `
447 | Array [
448 | undefined,
449 | Object {
450 | "price": 1,
451 | },
452 | ]
453 | `
454 |
455 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 7`] = `
456 | Array [
457 | undefined,
458 | Object {
459 | "price": 1.2,
460 | },
461 | ]
462 | `
463 |
464 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 8`] = `
465 | Array [
466 | "invalid price, must be non-negative number",
467 | Object {
468 | "price": -1.2,
469 | },
470 | ]
471 | `
472 |
473 | exports[`test/is.ts TAP validateFeatureTier > must match snapshot 9`] = `
474 | Array [
475 | "invalid upto, must be integer greater than 0",
476 | Object {
477 | "upto": -2,
478 | },
479 | ]
480 | `
481 |
482 | exports[`test/is.ts TAP validateModel > must match snapshot 1`] = `
483 | Array [
484 | "not an object",
485 | null,
486 | ]
487 | `
488 |
489 | exports[`test/is.ts TAP validateModel > must match snapshot 2`] = `
490 | Array [
491 | "not an object",
492 | true,
493 | ]
494 | `
495 |
496 | exports[`test/is.ts TAP validateModel > must match snapshot 3`] = `
497 | Array [
498 | "missing or invalid plans, must be object",
499 | Object {},
500 | ]
501 | `
502 |
503 | exports[`test/is.ts TAP validateModel > must match snapshot 4`] = `
504 | Array [
505 | undefined,
506 | Object {
507 | "plans": Object {},
508 | },
509 | ]
510 | `
511 |
512 | exports[`test/is.ts TAP validateModel > must match snapshot 5`] = `
513 | Array [
514 | undefined,
515 | Object {
516 | "plans": Object {
517 | "plan:p@0": Object {},
518 | },
519 | },
520 | ]
521 | `
522 |
523 | exports[`test/is.ts TAP validateModel > must match snapshot 6`] = `
524 | Array [
525 | "invalid plan name: not a plan name",
526 | Object {
527 | "plans": Object {
528 | "not a plan name": Object {},
529 | },
530 | },
531 | ]
532 | `
533 |
534 | exports[`test/is.ts TAP validateModel > must match snapshot 7`] = `
535 | Array [
536 | "plans['plan:notaplan@0']: invalid feature name: not a feature name",
537 | Object {
538 | "plans": Object {
539 | "plan:notaplan@0": Object {
540 | "features": Object {
541 | "not a feature name": Object {},
542 | },
543 | },
544 | },
545 | },
546 | ]
547 | `
548 |
549 | exports[`test/is.ts TAP validateModel > must match snapshot 8`] = `
550 | Array [
551 | "unexpected field(s): other",
552 | Object {
553 | "other": "stuff",
554 | "plans": Object {},
555 | },
556 | ]
557 | `
558 |
559 | exports[`test/is.ts TAP validateModel > must match snapshot 9`] = `
560 | Array [
561 | "plans['plan:x@1']: features['feature:name']: tiers[1]: unexpected field(s): x",
562 | Object {
563 | "plans": Object {
564 | "plan:x@1": Object {
565 | "features": Object {
566 | "feature:name": Object {
567 | "tiers": Array [
568 | Object {
569 | "upto": 1,
570 | },
571 | Object {
572 | "x": true,
573 | },
574 | ],
575 | },
576 | },
577 | },
578 | },
579 | },
580 | ]
581 | `
582 |
583 | exports[`test/is.ts TAP validatePlan > must match snapshot 1`] = `
584 | Array [
585 | "not an object",
586 | null,
587 | ]
588 | `
589 |
590 | exports[`test/is.ts TAP validatePlan > must match snapshot 10`] = `
591 | Array [
592 | "invalid currency: [object Object]",
593 | Object {
594 | "currency": Object {
595 | "not": "a currency string",
596 | },
597 | },
598 | ]
599 | `
600 |
601 | exports[`test/is.ts TAP validatePlan > must match snapshot 11`] = `
602 | Array [
603 | undefined,
604 | Object {
605 | "currency": "usd",
606 | },
607 | ]
608 | `
609 |
610 | exports[`test/is.ts TAP validatePlan > must match snapshot 12`] = `
611 | Array [
612 | "invalid interval: [object Object]",
613 | Object {
614 | "interval": Object {
615 | "not": "an interval string",
616 | },
617 | },
618 | ]
619 | `
620 |
621 | exports[`test/is.ts TAP validatePlan > must match snapshot 13`] = `
622 | Array [
623 | "invalid interval: not an interval string",
624 | Object {
625 | "interval": "not an interval string",
626 | },
627 | ]
628 | `
629 |
630 | exports[`test/is.ts TAP validatePlan > must match snapshot 14`] = `
631 | Array [
632 | undefined,
633 | Object {
634 | "interval": "@monthly",
635 | },
636 | ]
637 | `
638 |
639 | exports[`test/is.ts TAP validatePlan > must match snapshot 15`] = `
640 | Array [
641 | "unexpected field(s): another",
642 | Object {
643 | "another": "thing",
644 | },
645 | ]
646 | `
647 |
648 | exports[`test/is.ts TAP validatePlan > must match snapshot 2`] = `
649 | Array [
650 | "not an object",
651 | true,
652 | ]
653 | `
654 |
655 | exports[`test/is.ts TAP validatePlan > must match snapshot 3`] = `
656 | Array [
657 | "invalid title, must be string",
658 | Object {
659 | "title": Object {
660 | "not": "a string",
661 | },
662 | },
663 | ]
664 | `
665 |
666 | exports[`test/is.ts TAP validatePlan > must match snapshot 4`] = `
667 | Array [
668 | "invalid features field, must be object",
669 | Object {
670 | "features": null,
671 | },
672 | ]
673 | `
674 |
675 | exports[`test/is.ts TAP validatePlan > must match snapshot 5`] = `
676 | Array [
677 | "invalid features field, must be object",
678 | Object {
679 | "features": "not an object",
680 | },
681 | ]
682 | `
683 |
684 | exports[`test/is.ts TAP validatePlan > must match snapshot 6`] = `
685 | Array [
686 | "invalid feature name: not a feature name",
687 | Object {
688 | "features": Object {
689 | "not a feature name": Object {},
690 | },
691 | },
692 | ]
693 | `
694 |
695 | exports[`test/is.ts TAP validatePlan > must match snapshot 7`] = `
696 | Array [
697 | undefined,
698 | Object {},
699 | ]
700 | `
701 |
702 | exports[`test/is.ts TAP validatePlan > must match snapshot 8`] = `
703 | Array [
704 | undefined,
705 | Object {
706 | "features": Object {
707 | "feature:name": Object {},
708 | },
709 | },
710 | ]
711 | `
712 |
713 | exports[`test/is.ts TAP validatePlan > must match snapshot 9`] = `
714 | Array [
715 | "features['feature:name']: tiers[1]: unexpected field(s): x",
716 | Object {
717 | "features": Object {
718 | "feature:name": Object {
719 | "tiers": Array [
720 | Object {
721 | "upto": 1,
722 | },
723 | Object {
724 | "x": true,
725 | },
726 | ],
727 | },
728 | },
729 | },
730 | ]
731 | `
732 |
--------------------------------------------------------------------------------
/test/client.ts:
--------------------------------------------------------------------------------
1 | import { createServer } from 'http'
2 | import t from 'tap'
3 | import { Tier } from '../'
4 |
5 | // node 16 didn't have fetch built in
6 | import { default as NodeFetch } from 'node-fetch'
7 | //@ts-ignore
8 | if (!globalThis.fetch) globalThis.fetch = NodeFetch
9 | //@ts-ignore
10 | const f = globalThis.fetch
11 | //@ts-ignore
12 | globalThis.fetch = function (...args) {
13 | if (this && this !== globalThis) {
14 | throw new Error('can only call fetch() on globalThis')
15 | }
16 | return f.call(this, ...args)
17 | }
18 |
19 | const port = 10000 + (process.pid % 10000)
20 |
21 | t.test('debuglog', t => {
22 | const { error } = console
23 | t.teardown(() => {
24 | console.error = error
25 | })
26 | const logs: any[][] = []
27 | console.info = (...m: any[]) => logs.push(m)
28 | const apiKey = 'donotprintthisever'
29 | const tier = new Tier({
30 | baseURL: `http://localhost:${port}`,
31 | apiKey,
32 | userAgent: false,
33 | })
34 | //@ts-ignore
35 | tier.debugLog('hello')
36 | t.same(logs, [])
37 | //@ts-ignore
38 | tier.debug = true
39 | //@ts-ignore
40 | tier.debugLog('hello')
41 | t.same(logs, [['tier:', 'hello']])
42 |
43 | const server = createServer((req, res) => {
44 | t.equal(
45 | req.headers.authorization,
46 | `Basic ${Buffer.from(apiKey + ':').toString('base64')}`
47 | )
48 | res.setHeader('connection', 'close')
49 | res.end(JSON.stringify({ ok: true }))
50 | }).listen(port, async () => {
51 | //@ts-ignore
52 | const okGet = await tier.apiGet('/v1/get')
53 | t.same(okGet, { ok: true })
54 | //@ts-ignore
55 | const okPost = await tier.apiPost('/v1/post', { some: 'data' })
56 | t.same(okPost, { ok: true })
57 | const dumpLogs = JSON.stringify(logs)
58 | t.notMatch(dumpLogs, apiKey)
59 | t.notMatch(dumpLogs, Buffer.from(apiKey).toString('base64'))
60 | t.notMatch(dumpLogs, Buffer.from(apiKey + ':').toString('base64'))
61 | t.notMatch(dumpLogs, Buffer.from(':' + apiKey).toString('base64'))
62 | server.close()
63 | t.end()
64 | })
65 | })
66 |
67 | t.test('user agent', t => {
68 | t.test('user agent default from node process', t => {
69 | //@ts-ignore
70 | global.navigator = undefined
71 | global.process = global.process || { version: 'v4.2.0' }
72 | const { Tier } = t.mock('../', {})
73 | const tier = new Tier({ baseURL: 'http://x.com' })
74 | t.match(tier.userAgent, /^tier\/[^ ]+ [a-f0-9]{8} node\/[^ ]+$/)
75 | t.end()
76 | })
77 |
78 | t.test('user agent from navigator', t => {
79 | //@ts-ignore
80 | global.navigator = { userAgent: 'mozilla/1.2.3' }
81 | const { Tier } = t.mock('../', {})
82 | const tier = new Tier({ baseURL: 'http://x.com' })
83 | t.match(tier.userAgent, /^tier\/[^ ]+ [a-f0-9]{8} mozilla\/1.2.3$/)
84 | t.end()
85 | })
86 |
87 | t.test('user agent explicit', t => {
88 | const tier = new Tier({ baseURL: 'http://x.y', userAgent: 'hello' })
89 | t.equal(tier.userAgent, 'hello')
90 | t.end()
91 | })
92 |
93 | t.test('user agent false', t => {
94 | const tier = new Tier({ baseURL: 'http://x.y', userAgent: false })
95 | t.equal(tier.userAgent, false)
96 | t.end()
97 | })
98 |
99 | t.end()
100 | })
101 |
--------------------------------------------------------------------------------
/test/fetch-no-polyfill.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch'
2 | //@ts-ignore
3 | global.fetch = fetch
4 | import './index'
5 |
--------------------------------------------------------------------------------
/test/fetch-polyfill.ts:
--------------------------------------------------------------------------------
1 | //@ts-ignore
2 | global.fetch = undefined
3 | //@ts-ignore
4 | globalThis.fetch = undefined
5 |
6 | import './init'
7 |
--------------------------------------------------------------------------------
/test/index.ts:
--------------------------------------------------------------------------------
1 | const warnings: string[] = []
2 | Object.defineProperty(process, 'emitWarning', {
3 | value: (msg: string) => {
4 | warnings.push(msg)
5 | },
6 | })
7 |
8 | import { actualRequestUrl } from 'actual-request-url'
9 | import { createServer } from 'http'
10 | import { createServer as createNetServer } from 'net'
11 | import t from 'tap'
12 |
13 | import { default as NodeFetch } from 'node-fetch'
14 | import type { OrgInfo, PushResponse } from '../'
15 | import { Tier } from '../dist/cjs/client.js'
16 | import { LookupOrgResponseJSON, OrgInfoJSON } from '../dist/cjs/tier-types.js'
17 |
18 | const port = 10000 + (process.pid % 10000)
19 | const date = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$/
20 |
21 | // fake the init for these tests
22 | // don't need to spin up a real sidecar in CI
23 | let initCalled = false
24 |
25 | const { default: tier } = t.mock('../', {
26 | '../dist/cjs/get-client.js': {
27 | getClient: async (clientOptions?: TierGetClientOptions): Promise => {
28 | initCalled = true
29 | const baseURL = (process.env.TIER_BASE_URL = `http://localhost:${port}`)
30 | return new Tier({
31 | ...(clientOptions || {}),
32 | baseURL,
33 | //@ts-ignore
34 | fetchImpl: (globalThis.fetch || NodeFetch) as typeof fetch,
35 | })
36 | },
37 | },
38 | }) as typeof import('../')
39 |
40 | // un-mock this one.
41 | import { isTierError, TierError, TierGetClientOptions } from '../'
42 | tier.isTierError = isTierError
43 |
44 | t.match(tier, {
45 | isOrgName: Function,
46 | isFeatureName: Function,
47 | isPlanName: Function,
48 | isFeatureNameVersioned: Function,
49 | isFeatures: Function,
50 | isPhase: Function,
51 | validatePlan: Function,
52 | validateModel: Function,
53 | validateFeatureTier: Function,
54 | validateFeatureDefinition: Function,
55 | limits: Function,
56 | limit: Function,
57 | report: Function,
58 | subscribe: Function,
59 | whois: Function,
60 | whoami: Function,
61 | phase: Function,
62 | push: Function,
63 | lookupOrg: Function,
64 | updateOrg: Function,
65 | checkout: Function,
66 | withClock: Function,
67 | })
68 |
69 | t.equal(initCalled, false, 'have not called init')
70 |
71 | t.test('type checks', async t => {
72 | t.equal(tier.isOrgName('org:foo'), true)
73 | t.equal(tier.isOrgName('foo'), false)
74 | t.equal(tier.isPhase({}), false)
75 | t.equal(tier.isPhase({ trial: 123 }), false)
76 | t.equal(tier.isFeatures('plan:ok@1'), true)
77 | t.equal(tier.isFeatures('feature:yup@plan:ok@1'), true)
78 | t.equal(tier.isFeatures('feature:nope'), false)
79 | t.equal(tier.isFeatures('feature:nope@2'), false)
80 | t.equal(tier.isFeatureNameVersioned('feature:nope'), false)
81 | t.equal(tier.isFeatureNameVersioned('feature:yup@plan:ok@1'), true)
82 | t.equal(tier.isFeatureName('feature:yup'), true)
83 | t.equal(tier.isFeatureName('nope'), false)
84 | t.equal(
85 | tier.isPhase({
86 | features: ['feature:foo@plan:bar@1', 'plan:bar@2'],
87 | }),
88 | true
89 | )
90 | t.equal(
91 | tier.isPhase({
92 | effective: 'not a date',
93 | features: ['feature:foo@plan:bar@1', 'plan:bar@2'],
94 | }),
95 | false
96 | )
97 | t.equal(
98 | tier.isPhase({
99 | effective: new Date(),
100 | features: [
101 | 'feature:foo@plan:bar@1',
102 | 'plan:bar@2',
103 | 'feature:not features',
104 | ],
105 | }),
106 | false
107 | )
108 | })
109 |
110 | t.test('lookupLimits', t => {
111 | const server = createServer((req, res) => {
112 | res.setHeader('connection', 'close')
113 | server.close()
114 | t.equal(req.method, 'GET')
115 | t.match(
116 | req.headers['user-agent'],
117 | new RegExp(`tier\/[^ ]+ [a-f0-9]{8} node\/${process.version}$`)
118 | )
119 | t.equal(req.url, '/v1/limits?org=org%3Ao')
120 | res.end(JSON.stringify({ ok: true }))
121 | })
122 | server.listen(port, async () => {
123 | t.same(await tier.lookupLimits('org:o'), { ok: true })
124 | t.end()
125 | })
126 | })
127 |
128 | t.test('lookupLimit', t => {
129 | let reqs = 0
130 | const server = createServer((req, res) => {
131 | res.setHeader('connection', 'close')
132 | if (reqs++ === 1) {
133 | server.close()
134 | }
135 | t.equal(req.method, 'GET')
136 | t.equal(req.url, '/v1/limits?org=org%3Ao')
137 | res.end(
138 | JSON.stringify({
139 | org: 'org:o',
140 | usage: [
141 | {
142 | feature: 'feature:storage',
143 | used: 341,
144 | limit: 10000,
145 | },
146 | {
147 | feature: 'feature:transfer',
148 | used: 234213,
149 | limit: 10000,
150 | },
151 | ],
152 | })
153 | )
154 | })
155 | server.listen(port, async () => {
156 | t.same(await tier.lookupLimit('org:o', 'feature:storage'), {
157 | feature: 'feature:storage',
158 | used: 341,
159 | limit: 10000,
160 | })
161 | t.same(await tier.lookupLimit('org:o', 'feature:other'), {
162 | feature: 'feature:other',
163 | used: 0,
164 | limit: 0,
165 | })
166 | t.end()
167 | })
168 | })
169 |
170 | t.test('pull', t => {
171 | const server = createServer((req, res) => {
172 | res.setHeader('connection', 'close')
173 | server.close()
174 | t.equal(req.method, 'GET')
175 | t.equal(req.url, '/v1/pull')
176 | res.end(JSON.stringify({ plans: {} }))
177 | })
178 | server.listen(port, async () => {
179 | t.same(await tier.pull(), { plans: {} })
180 | t.end()
181 | })
182 | })
183 |
184 | t.test('pullLatest', t => {
185 | const server = createServer((req, res) => {
186 | res.setHeader('connection', 'close')
187 | server.close()
188 | t.equal(req.method, 'GET')
189 | t.equal(req.url, '/v1/pull')
190 | res.end(
191 | JSON.stringify({
192 | plans: {
193 | 'plan:mixednum@9test': {},
194 | 'plan:mixednum@9999999': {},
195 | 'plan:mixednum@0test': {},
196 | 'plan:mixednum@1000': {},
197 | 'plan:alpha@dog': {},
198 | 'plan:alpha@cat': {},
199 | 'plan:longnum@1': {},
200 | 'plan:longnum@888': {},
201 | 'plan:longnum@1000': {},
202 | 'plan:longnum@99': {},
203 | 'plan:foo@1': {},
204 | 'plan:foo@0': {},
205 | 'plan:bar@7': {},
206 | 'plan:foo@2': {},
207 | 'plan:bar@0': {},
208 | },
209 | })
210 | )
211 | })
212 | server.listen(port, async () => {
213 | t.same(await tier.pullLatest(), {
214 | plans: {
215 | 'plan:foo@2': {},
216 | 'plan:bar@7': {},
217 | 'plan:longnum@1000': {},
218 | 'plan:alpha@dog': {},
219 | 'plan:mixednum@9test': {},
220 | },
221 | })
222 | t.end()
223 | })
224 | })
225 |
226 | t.test('lookupPhase', t => {
227 | t.teardown(() => {
228 | server.close()
229 | })
230 | const server = createServer((req, res) => {
231 | res.setHeader('connection', 'close')
232 | t.equal(req.method, 'GET')
233 | if (!req.url) throw new Error('no url on req')
234 | t.equal(req.url.startsWith('/v1/phase?org=org%3A'), true)
235 | const phase = req.url.endsWith('o')
236 | ? {
237 | effective: '2022-10-13T16:52:11-07:00',
238 | features: [
239 | 'feature:storage@plan:free@1',
240 | 'feature:transfer@plan:free@1',
241 | ],
242 | plans: ['plan:free@1'],
243 | current: {
244 | effective: '2022-10-13T16:52:11-07:00',
245 | end: '2022-11-13T16:52:11-07:00',
246 | },
247 | }
248 | : {
249 | effective: '2022-10-13T16:52:11-07:00',
250 | end: '2025-01-01T00:00:00.000Z',
251 | features: [
252 | 'feature:storage@plan:free@1',
253 | 'feature:transfer@plan:free@1',
254 | ],
255 | plans: ['plan:free@1'],
256 | trial: true,
257 | current: {
258 | effective: '2022-10-13T16:52:11-07:00',
259 | end: '2025-01-01T00:00:00.000Z',
260 | },
261 | }
262 | res.end(JSON.stringify(phase))
263 | })
264 |
265 | server.listen(port, async () => {
266 | t.same(await tier.lookupPhase('org:o'), {
267 | effective: new Date('2022-10-13T16:52:11-07:00'),
268 | end: undefined,
269 | features: ['feature:storage@plan:free@1', 'feature:transfer@plan:free@1'],
270 | plans: ['plan:free@1'],
271 | trial: false,
272 | current: {
273 | effective: new Date('2022-10-13T16:52:11-07:00'),
274 | end: new Date('2022-11-13T16:52:11-07:00'),
275 | },
276 | })
277 | t.same(await tier.lookupPhase('org:p'), {
278 | effective: new Date('2022-10-13T16:52:11-07:00'),
279 | end: new Date('2025-01-01T00:00:00.000Z'),
280 | features: ['feature:storage@plan:free@1', 'feature:transfer@plan:free@1'],
281 | plans: ['plan:free@1'],
282 | trial: true,
283 | current: {
284 | effective: new Date('2022-10-13T16:52:11-07:00'),
285 | end: new Date('2025-01-01T00:00:00.000Z'),
286 | },
287 | })
288 | t.end()
289 | })
290 | })
291 |
292 | t.test('whois', t => {
293 | const server = createServer((req, res) => {
294 | res.setHeader('connection', 'close')
295 | server.close()
296 | t.equal(req.method, 'GET')
297 | t.equal(req.url, '/v1/whois?org=org%3Ao')
298 | res.end(JSON.stringify({ org: 'org:o', stripe_id: 'cust_1234' }))
299 | })
300 | server.listen(port, async () => {
301 | t.same(await tier.whois('org:o'), { org: 'org:o', stripe_id: 'cust_1234' })
302 | t.end()
303 | })
304 | })
305 |
306 | t.test('whoami', t => {
307 | const server = createServer((req, res) => {
308 | res.setHeader('connection', 'close')
309 | server.close()
310 | t.equal(req.method, 'GET')
311 | t.equal(req.url, '/v1/whoami')
312 | res.end(JSON.stringify({ ok: true }))
313 | })
314 | server.listen(port, async () => {
315 | t.same(await tier.whoami(), { ok: true })
316 | t.end()
317 | })
318 | })
319 |
320 | t.test('report', t => {
321 | const expects = [
322 | {
323 | org: 'org:o',
324 | feature: 'feature:f',
325 | n: 1,
326 | clobber: false,
327 | },
328 | {
329 | org: 'org:o',
330 | feature: 'feature:f',
331 | at: '2022-10-24T21:26:24.438Z',
332 | n: 10,
333 | clobber: true,
334 | },
335 | ]
336 |
337 | const server = createServer((req, res) => {
338 | res.setHeader('connection', 'close')
339 | t.equal(req.method, 'POST')
340 | const chunks: Buffer[] = []
341 | req.on('data', c => chunks.push(c))
342 | req.on('end', () => {
343 | const body = JSON.parse(Buffer.concat(chunks).toString())
344 | t.same(body, expects.shift())
345 | res.end(JSON.stringify({ ok: true }))
346 | if (!expects.length) {
347 | server.close()
348 | }
349 | })
350 | })
351 |
352 | server.listen(port, async () => {
353 | t.same(await tier.report('org:o', 'feature:f'), { ok: true })
354 | t.same(
355 | await tier.report('org:o', 'feature:f', 10, {
356 | at: new Date('2022-10-24T21:26:24.438Z'),
357 | clobber: true,
358 | }),
359 | { ok: true }
360 | )
361 | t.end()
362 | })
363 | })
364 |
365 | t.test('checkout', t => {
366 | const checkoutRes = {
367 | url: 'https://www.example.com/checkout',
368 | }
369 |
370 | let expect: { [k: string]: any } = { nope: 'invalid' }
371 |
372 | const server = createServer((req, res) => {
373 | res.setHeader('connection', 'close')
374 | t.equal(req.method, 'POST')
375 | t.equal(req.url, '/v1/checkout')
376 | const chunks: Buffer[] = []
377 | req.on('data', c => chunks.push(c))
378 | req.on('end', () => {
379 | const body = JSON.parse(Buffer.concat(chunks).toString())
380 | t.match(body, expect)
381 | expect = { nope: 'invalid' }
382 | res.end(JSON.stringify(checkoutRes))
383 | })
384 | })
385 |
386 | server.listen(port, async () => {
387 | expect = { org: 'org:o', success_url: 'http://success' }
388 | t.same(await tier.checkout('org:o', 'http://success'), checkoutRes)
389 |
390 | expect = { org: 'org:o', success_url: 'http://success' }
391 | t.same(await tier.checkout('org:o', 'http://success', {}), checkoutRes)
392 |
393 | expect = { org: 'org:o', success_url: 'http://success' }
394 | t.same(
395 | await tier.checkout('org:o', 'http://success', {
396 | trialDays: 99,
397 | }),
398 | checkoutRes
399 | )
400 |
401 | expect = {
402 | org: 'org:o',
403 | success_url: 'http://success',
404 | features: ['plan:p@1'],
405 | }
406 | t.same(
407 | await tier.checkout('org:o', 'http://success', {
408 | features: 'plan:p@1',
409 | }),
410 | checkoutRes
411 | )
412 |
413 | expect = {
414 | org: 'org:o',
415 | success_url: 'http://success',
416 | features: ['feature:foo@plan:x@1', 'plan:p@1'],
417 | }
418 | t.same(
419 | await tier.checkout('org:o', 'http://success', {
420 | features: ['feature:foo@plan:x@1', 'plan:p@1'],
421 | }),
422 | checkoutRes
423 | )
424 |
425 | expect = {
426 | org: 'org:o',
427 | success_url: 'http://success',
428 | features: ['plan:p@1'],
429 | trial_days: 99,
430 | }
431 | t.same(
432 | await tier.checkout('org:o', 'http://success', {
433 | features: 'plan:p@1',
434 | trialDays: 99,
435 | }),
436 | checkoutRes
437 | )
438 |
439 | expect = {
440 | org: 'org:o',
441 | success_url: 'http://success',
442 | cancel_url: 'https://cancel/',
443 | }
444 | t.same(
445 | await tier.checkout('org:o', 'http://success', {
446 | cancelUrl: 'https://cancel/',
447 | }),
448 | checkoutRes
449 | )
450 |
451 | expect = {
452 | org: 'org:o',
453 | success_url: 'http://success',
454 | cancel_url: 'https://cancel/',
455 | }
456 | t.same(
457 | await tier.checkout('org:o', 'http://success', {
458 | cancelUrl: 'https://cancel/',
459 | trialDays: 99,
460 | }),
461 | checkoutRes
462 | )
463 |
464 | expect = {
465 | org: 'org:o',
466 | success_url: 'http://success',
467 | cancel_url: 'https://cancel/',
468 | features: ['plan:p@1'],
469 | }
470 | t.same(
471 | await tier.checkout('org:o', 'http://success', {
472 | cancelUrl: 'https://cancel/',
473 | features: 'plan:p@1',
474 | }),
475 | checkoutRes
476 | )
477 |
478 | expect = {
479 | org: 'org:o',
480 | success_url: 'http://success',
481 | cancel_url: 'https://cancel/',
482 | features: ['feature:foo@plan:x@1', 'plan:p@1'],
483 | }
484 | t.same(
485 | await tier.checkout('org:o', 'http://success', {
486 | cancelUrl: 'https://cancel/',
487 | features: ['feature:foo@plan:x@1', 'plan:p@1'],
488 | }),
489 | checkoutRes
490 | )
491 |
492 | expect = {
493 | org: 'org:o',
494 | success_url: 'http://success',
495 | cancel_url: 'https://cancel/',
496 | features: ['plan:p@1'],
497 | trial_days: 99,
498 | }
499 | t.same(
500 | await tier.checkout('org:o', 'http://success', {
501 | cancelUrl: 'https://cancel/',
502 | features: 'plan:p@1',
503 | trialDays: 99,
504 | }),
505 | checkoutRes
506 | )
507 |
508 | expect = {
509 | org: 'org:o',
510 | success_url: 'http://success',
511 | cancel_url: 'https://cancel/',
512 | features: ['plan:p@1'],
513 | trial_days: 99,
514 | tax: {
515 | automatic: true,
516 | },
517 | }
518 | t.same(
519 | await tier.checkout('org:o', 'http://success', {
520 | cancelUrl: 'https://cancel/',
521 | features: 'plan:p@1',
522 | trialDays: 99,
523 | tax: {
524 | automatic: true,
525 | },
526 | }),
527 | checkoutRes
528 | )
529 |
530 | expect = {
531 | org: 'org:o',
532 | success_url: 'http://success',
533 | cancel_url: 'https://cancel/',
534 | features: ['plan:p@1'],
535 | trial_days: 99,
536 | tax: {
537 | collect_id: true,
538 | },
539 | }
540 | t.same(
541 | await tier.checkout('org:o', 'http://success', {
542 | cancelUrl: 'https://cancel/',
543 | features: 'plan:p@1',
544 | trialDays: 99,
545 | tax: {
546 | collectId: true,
547 | },
548 | }),
549 | checkoutRes
550 | )
551 |
552 | expect = {
553 | org: 'org:o',
554 | success_url: 'http://success',
555 | cancel_url: 'https://cancel/',
556 | features: ['plan:p@1'],
557 | trial_days: 99,
558 | tax: {
559 | automatic: true,
560 | collect_id: true,
561 | },
562 | }
563 | t.same(
564 | await tier.checkout('org:o', 'http://success', {
565 | cancelUrl: 'https://cancel/',
566 | features: 'plan:p@1',
567 | trialDays: 99,
568 | tax: {
569 | automatic: true,
570 | collectId: true,
571 | },
572 | }),
573 | checkoutRes
574 | )
575 |
576 | server.close()
577 | t.end()
578 | })
579 | })
580 |
581 | t.test('subscribe', t => {
582 | const orgInfo: OrgInfo = {
583 | email: 'o@o.org',
584 | name: 'Orggy Org',
585 | description: 'describe them lolol',
586 | phone: '+15558675309',
587 | metadata: {},
588 | }
589 |
590 | const expects = [
591 | {
592 | org: 'org:o',
593 | phases: [
594 | {
595 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
596 | },
597 | ],
598 | },
599 | { org: 'org:o', phases: [{ features: ['plan:basic@0'] }] },
600 | {
601 | org: 'org:o',
602 | phases: [
603 | {
604 | effective: '2022-10-24T21:26:24.438Z',
605 | features: ['plan:basic@0', 'feature:f@plan:p@0'],
606 | },
607 | ],
608 | },
609 | {
610 | org: 'org:o',
611 | phases: [
612 | {
613 | features: ['plan:basic@0', 'feature:f@plan:p@0'],
614 | trial: true,
615 | effective: '2022-10-24T21:26:24.438Z',
616 | },
617 | {
618 | effective: '2022-10-25T21:26:24.438Z',
619 | features: ['plan:basic@0', 'feature:f@plan:p@0'],
620 | },
621 | ],
622 | },
623 | {
624 | org: 'org:o',
625 | phases: [
626 | {
627 | features: ['plan:basic@0', 'feature:f@plan:p@0'],
628 | trial: true,
629 | effective: undefined,
630 | },
631 | {
632 | effective: date,
633 | features: ['plan:basic@0', 'feature:f@plan:p@0'],
634 | },
635 | ],
636 | },
637 | {
638 | org: 'org:o',
639 | phases: [],
640 | info: orgInfo,
641 | },
642 | ]
643 |
644 | const server = createServer((req, res) => {
645 | res.setHeader('connection', 'close')
646 | t.equal(req.method, 'POST')
647 | const chunks: Buffer[] = []
648 | req.on('data', c => chunks.push(c))
649 | req.on('end', () => {
650 | const body = JSON.parse(Buffer.concat(chunks).toString())
651 | t.match(body, expects.shift())
652 | res.end(JSON.stringify({ ok: true }))
653 | if (!expects.length) {
654 | server.close()
655 | }
656 | })
657 | })
658 |
659 | server.listen(port, async () => {
660 | t.same(
661 | await tier.subscribe('org:o', ['feature:foo@plan:bar@1', 'plan:pro@2']),
662 | { ok: true }
663 | )
664 |
665 | t.same(await tier.subscribe('org:o', 'plan:basic@0'), { ok: true })
666 |
667 | t.same(
668 | await tier.subscribe('org:o', ['plan:basic@0', 'feature:f@plan:p@0'], {
669 | effective: new Date('2022-10-24T21:26:24.438Z'),
670 | }),
671 | { ok: true }
672 | )
673 |
674 | t.same(
675 | await tier.subscribe('org:o', ['plan:basic@0', 'feature:f@plan:p@0'], {
676 | effective: new Date('2022-10-24T21:26:24.438Z'),
677 | trialDays: 1,
678 | }),
679 | { ok: true }
680 | )
681 |
682 | t.same(
683 | await tier.subscribe('org:o', ['plan:basic@0', 'feature:f@plan:p@0'], {
684 | trialDays: 1,
685 | }),
686 | { ok: true }
687 | )
688 |
689 | t.same(await tier.subscribe('org:o', [], { info: orgInfo }), { ok: true })
690 |
691 | await t.rejects(
692 | tier.subscribe('org:o', ['plan:basic@0', 'feature:f@plan:p@0'], {
693 | effective: new Date('2022-10-24T21:26:24.438Z'),
694 | trialDays: -1,
695 | }),
696 | { message: 'trialDays must be number >0 if specified' }
697 | )
698 |
699 | await t.rejects(
700 | tier.subscribe('org:o', [], {
701 | trialDays: 1,
702 | }),
703 | { message: 'trialDays may not be set without a subscription' }
704 | )
705 |
706 | await t.rejects(
707 | tier.subscribe(
708 | 'org:o',
709 | [
710 | // @ts-ignore
711 | {
712 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
713 | },
714 | ],
715 | new Date('2022-10-24T21:26:24.438Z')
716 | )
717 | )
718 |
719 | t.end()
720 | })
721 | })
722 |
723 | t.test('cancel', t => {
724 | const expects = [
725 | {
726 | org: 'org:o',
727 | phases: [{}],
728 | },
729 | ]
730 |
731 | const server = createServer((req, res) => {
732 | res.setHeader('connection', 'close')
733 | t.equal(req.method, 'POST')
734 | const chunks: Buffer[] = []
735 | req.on('data', c => chunks.push(c))
736 | req.on('end', () => {
737 | const body = JSON.parse(Buffer.concat(chunks).toString())
738 | t.same(body, expects.shift())
739 | res.end(JSON.stringify({ ok: true }))
740 | if (!expects.length) {
741 | server.close()
742 | }
743 | })
744 | })
745 |
746 | server.listen(port, async () => {
747 | t.same(await tier.cancel('org:o'), { ok: true })
748 |
749 | t.end()
750 | })
751 | })
752 |
753 | t.test('schedule', t => {
754 | const expects = [
755 | {
756 | org: 'org:o',
757 | phases: [
758 | {
759 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
760 | },
761 | ],
762 | },
763 | {
764 | org: 'org:o',
765 | phases: [
766 | {
767 | effective: '2022-10-24T21:26:24.438Z',
768 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
769 | },
770 | {
771 | effective: '2023-10-24T21:26:24.438Z',
772 | features: ['feature:foo@plan:enterprise@1', 'plan:enterprise@2'],
773 | },
774 | ],
775 | },
776 | ]
777 |
778 | const server = createServer((req, res) => {
779 | res.setHeader('connection', 'close')
780 | t.equal(req.method, 'POST')
781 | const chunks: Buffer[] = []
782 | req.on('data', c => chunks.push(c))
783 | req.on('end', () => {
784 | const body = JSON.parse(Buffer.concat(chunks).toString())
785 | t.same(body, expects.shift())
786 | res.end(JSON.stringify({ ok: true }))
787 | if (!expects.length) {
788 | server.close()
789 | }
790 | })
791 | })
792 |
793 | server.listen(port, async () => {
794 | t.same(
795 | await tier.schedule('org:o', [
796 | {
797 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
798 | },
799 | ]),
800 | { ok: true }
801 | )
802 | t.same(
803 | await tier.schedule('org:o', [
804 | {
805 | effective: new Date('2022-10-24T21:26:24.438Z'),
806 | features: ['feature:foo@plan:bar@1', 'plan:pro@2'],
807 | },
808 | {
809 | effective: new Date('2023-10-24T21:26:24.438Z'),
810 | features: ['feature:foo@plan:enterprise@1', 'plan:enterprise@2'],
811 | },
812 | ]),
813 | { ok: true }
814 | )
815 |
816 | t.end()
817 | })
818 | })
819 |
820 | t.test('push', t => {
821 | const expect = {
822 | plans: {
823 | 'plan:foo@1': {
824 | features: {
825 | 'feature:bar': {},
826 | },
827 | },
828 | },
829 | }
830 | const response: PushResponse = {
831 | results: [
832 | {
833 | feature: 'feature:bar@plan:foo@1',
834 | status: 'ok',
835 | reason: 'created',
836 | },
837 | ],
838 | }
839 | const server = createServer((req, res) => {
840 | res.setHeader('connection', 'close')
841 | server.close()
842 | t.equal(req.method, 'POST')
843 | t.equal(req.url, '/v1/push')
844 | const chunks: Buffer[] = []
845 | req.on('data', c => chunks.push(c))
846 | req.on('end', () => {
847 | const body = JSON.parse(Buffer.concat(chunks).toString())
848 | t.same(body, expect)
849 | res.end(JSON.stringify(response))
850 | })
851 | })
852 |
853 | server.listen(port, async () => {
854 | const actual = await tier.push(expect)
855 | t.same(actual, response)
856 | t.end()
857 | })
858 | })
859 |
860 | t.test('error GET', t => {
861 | const server = createServer((req, res) => {
862 | res.setHeader('connection', 'close')
863 | server.close()
864 | t.equal(req.method, 'GET')
865 | t.equal(req.url, '/v1/whois?org=org%3Ao')
866 | res.statusCode = 404
867 | res.end(
868 | JSON.stringify({
869 | status: 404,
870 | code: 'not_found',
871 | message: 'Not Found',
872 | })
873 | )
874 | })
875 | server.listen(port, async () => {
876 | await t.rejects(tier.whois('org:o'), {
877 | status: 404,
878 | code: 'not_found',
879 | message: 'Not Found',
880 | requestData: { org: 'org:o' },
881 | })
882 | t.end()
883 | })
884 | })
885 |
886 | t.test('error POST', t => {
887 | const server = createServer((req, res) => {
888 | res.setHeader('connection', 'close')
889 | server.close()
890 | t.equal(req.method, 'GET')
891 | t.equal(req.url, '/v1/whois?org=org%3Ao')
892 | res.statusCode = 404
893 | res.end(
894 | JSON.stringify({
895 | status: 404,
896 | code: 'not_found',
897 | message: 'Not Found',
898 | })
899 | )
900 | })
901 | server.listen(port, async () => {
902 | await t.rejects(tier.whois('org:o'), {
903 | status: 404,
904 | code: 'not_found',
905 | message: 'Not Found',
906 | requestData: { org: 'org:o' },
907 | })
908 | t.end()
909 | })
910 | })
911 |
912 | t.test('error POST', t => {
913 | const expect = {
914 | org: 'org:o',
915 | feature: 'feature:f',
916 | n: 1,
917 | clobber: false,
918 | }
919 |
920 | const server = createServer((req, res) => {
921 | res.setHeader('connection', 'close')
922 | t.equal(req.method, 'POST')
923 | const chunks: Buffer[] = []
924 | req.on('data', c => chunks.push(c))
925 | req.on('end', () => {
926 | const body = JSON.parse(Buffer.concat(chunks).toString())
927 | t.same(body, expect)
928 | res.statusCode = 404
929 | res.end(
930 | JSON.stringify({
931 | status: 404,
932 | code: 'not_found',
933 | message: 'Not Found',
934 | })
935 | )
936 | server.close()
937 | })
938 | })
939 |
940 | server.listen(port, async () => {
941 | await t.rejects(tier.report('org:o', 'feature:f'), {
942 | status: 404,
943 | code: 'not_found',
944 | message: 'Not Found',
945 | requestData: expect,
946 | })
947 | t.end()
948 | })
949 | })
950 |
951 | t.test('weird error GET', t => {
952 | const server = createServer((req, res) => {
953 | res.setHeader('connection', 'close')
954 | server.close()
955 | t.equal(req.method, 'GET')
956 | t.equal(req.url, '/v1/whois?org=org%3Ao')
957 | res.end('wtf lol')
958 | })
959 | server.listen(port, async () => {
960 | await t.rejects(tier.whois('org:o'), {
961 | status: 200,
962 | code: undefined,
963 | message: 'Tier request failed',
964 | requestData: { org: 'org:o' },
965 | responseData: 'wtf lol',
966 | })
967 | t.end()
968 | })
969 | })
970 |
971 | t.test('weird error POST', t => {
972 | const expect = {
973 | org: 'org:o',
974 | feature: 'feature:f',
975 | n: 1,
976 | clobber: false,
977 | }
978 |
979 | const server = createServer((req, res) => {
980 | res.setHeader('connection', 'close')
981 | t.equal(req.method, 'POST')
982 | const chunks: Buffer[] = []
983 | req.on('data', c => chunks.push(c))
984 | req.on('end', () => {
985 | const body = JSON.parse(Buffer.concat(chunks).toString())
986 | t.same(body, expect)
987 | res.statusCode = 500
988 | res.end('not json lol')
989 | server.close()
990 | })
991 | })
992 |
993 | server.listen(port, async () => {
994 | await t.rejects(
995 | tier.report('org:o', 'feature:f').catch((e: any) => {
996 | t.ok(tier.isTierError(e))
997 | throw e
998 | }),
999 | {
1000 | status: 500,
1001 | message: 'Tier request failed',
1002 | requestData: expect,
1003 | responseData: 'not json lol',
1004 | }
1005 | )
1006 | t.end()
1007 | })
1008 | })
1009 |
1010 | t.test('API server that is completely broken', t => {
1011 | const server = createNetServer(socket => {
1012 | socket.end(`HTTP/1.1 200 Ok\r
1013 | here: we go with http i promise
1014 |
1015 | just kidding here is a pigeon
1016 | __
1017 | <( O)
1018 | ||
1019 | ||____/|
1020 | ( > /
1021 | \___/
1022 | ||
1023 | _||_
1024 |
1025 | your welcome
1026 | `)
1027 | })
1028 |
1029 | server.listen(port, async () => {
1030 | try {
1031 | await tier.whoami()
1032 | t.fail('this should not work, pigeons are not API servers')
1033 | } catch (er) {
1034 | t.equal(isTierError(er), true)
1035 | t.match(
1036 | (er as TierError)?.cause,
1037 | Error,
1038 | 'got an Error object as the cause'
1039 | )
1040 | }
1041 | try {
1042 | await tier.report('org:o', 'feature:f')
1043 | t.fail('this should not work, pigeons are not API servers')
1044 | } catch (er) {
1045 | t.equal(isTierError(er), true)
1046 | t.match(
1047 | (er as TierError)?.cause,
1048 | Error,
1049 | 'got an Error object as the cause'
1050 | )
1051 | }
1052 | // now with onError
1053 | let onErrorsCalled = 0
1054 | const onError = (er: TierError) => {
1055 | onErrorsCalled++
1056 | t.equal(isTierError(er), true)
1057 | t.match((er as TierError).cause, Error, 'got error object as cause')
1058 | }
1059 | const tc = new Tier({ baseURL: `http://localhost:${port}`, onError })
1060 | // should not throw now, onError catches it
1061 | await tc.whoami()
1062 | await tc.report('org:o', 'feature:f')
1063 | t.equal(onErrorsCalled, 2, 'caught two errors')
1064 | server.close()
1065 | t.end()
1066 | })
1067 | })
1068 |
1069 | t.test('API server that hangs up right away', t => {
1070 | const server = createServer((_req, res) => {
1071 | res.setHeader('connection', 'close')
1072 | res.end()
1073 | })
1074 |
1075 | server.listen(port, async () => {
1076 | try {
1077 | await tier.whoami()
1078 | t.fail('this should not work, pigeons are not API servers')
1079 | } catch (er) {
1080 | t.equal(isTierError(er), true)
1081 | t.match(
1082 | (er as TierError)?.cause,
1083 | Error,
1084 | 'got an Error object as the cause'
1085 | )
1086 | }
1087 | try {
1088 | await tier.report('org:o', 'feature:f')
1089 | t.fail('this should not work, pigeons are not API servers')
1090 | } catch (er) {
1091 | t.equal(isTierError(er), true)
1092 | t.match(
1093 | (er as TierError)?.cause,
1094 | Error,
1095 | 'got an Error object as the cause'
1096 | )
1097 | }
1098 | server.close()
1099 | t.end()
1100 | })
1101 | })
1102 |
1103 | t.test('updateOrg', t => {
1104 | const expect: OrgInfoJSON = {
1105 | email: 'x@y.com',
1106 | name: 'Test User',
1107 | description: '',
1108 | phone: '+15558675309',
1109 | metadata: {
1110 | ok: 'true',
1111 | },
1112 | invoice_settings: {
1113 | default_payment_method: '',
1114 | },
1115 | }
1116 | const response = {}
1117 | const server = createServer((req, res) => {
1118 | res.setHeader('connection', 'close')
1119 | server.close()
1120 | t.equal(req.method, 'POST')
1121 | t.equal(req.url, '/v1/subscribe')
1122 | const chunks: Buffer[] = []
1123 | req.on('data', c => chunks.push(c))
1124 | req.on('end', () => {
1125 | const body = JSON.parse(Buffer.concat(chunks).toString())
1126 | t.same(body, { org: 'org:o', info: expect })
1127 | res.end(JSON.stringify(response))
1128 | })
1129 | })
1130 |
1131 | const request: OrgInfo = {
1132 | email: 'x@y.com',
1133 | name: 'Test User',
1134 | description: '',
1135 | phone: '+15558675309',
1136 | metadata: {
1137 | ok: 'true',
1138 | },
1139 | invoiceSettings: {
1140 | defaultPaymentMethod: '',
1141 | },
1142 | }
1143 | server.listen(port, async () => {
1144 | const actual = await tier.updateOrg('org:o', request)
1145 | t.same(actual, response)
1146 | t.end()
1147 | })
1148 | })
1149 |
1150 | t.test('updateOrg, no invoice settings sent', t => {
1151 | const expect: OrgInfoJSON = {
1152 | email: 'x@y.com',
1153 | name: 'Test User',
1154 | description: '',
1155 | phone: '+15558675309',
1156 | metadata: {
1157 | ok: 'true',
1158 | },
1159 | invoice_settings: {
1160 | default_payment_method: '',
1161 | },
1162 | }
1163 | const response = {}
1164 | const server = createServer((req, res) => {
1165 | res.setHeader('connection', 'close')
1166 | server.close()
1167 | t.equal(req.method, 'POST')
1168 | t.equal(req.url, '/v1/subscribe')
1169 | const chunks: Buffer[] = []
1170 | req.on('data', c => chunks.push(c))
1171 | req.on('end', () => {
1172 | const body = JSON.parse(Buffer.concat(chunks).toString())
1173 | t.same(body, { org: 'org:o', info: expect })
1174 | res.end(JSON.stringify(response))
1175 | })
1176 | })
1177 |
1178 | const request: OrgInfo = {
1179 | email: 'x@y.com',
1180 | name: 'Test User',
1181 | description: '',
1182 | phone: '+15558675309',
1183 | metadata: {
1184 | ok: 'true',
1185 | },
1186 | }
1187 | server.listen(port, async () => {
1188 | const actual = await tier.updateOrg('org:o', request)
1189 | t.same(actual, response)
1190 | t.end()
1191 | })
1192 | })
1193 |
1194 | t.test('lookupOrg', t => {
1195 | const response: LookupOrgResponseJSON = {
1196 | org: 'org:o',
1197 | name: '',
1198 | description: '',
1199 | phone: '+15558675309',
1200 | metadata: {},
1201 | stripe_id: 'cust_1234',
1202 | email: 'x@y.com',
1203 | invoice_settings: {
1204 | default_payment_method: 'pm_card_FAKE',
1205 | },
1206 | }
1207 | const server = createServer((req, res) => {
1208 | res.setHeader('connection', 'close')
1209 | server.close()
1210 | t.equal(req.method, 'GET')
1211 | t.equal(req.url, '/v1/whois?org=org%3Ao&include=info')
1212 | res.end(JSON.stringify(response))
1213 | })
1214 | server.listen(port, async () => {
1215 | t.same(await tier.lookupOrg('org:o'), {
1216 | org: 'org:o',
1217 | name: '',
1218 | description: '',
1219 | phone: '+15558675309',
1220 | metadata: {},
1221 | stripe_id: 'cust_1234',
1222 | email: 'x@y.com',
1223 | invoiceSettings: {
1224 | defaultPaymentMethod: 'pm_card_FAKE',
1225 | },
1226 | })
1227 | t.end()
1228 | })
1229 | })
1230 |
1231 | t.test('lookupOrg, no payment method in response', t => {
1232 | const response = {
1233 | org: 'org:o',
1234 | name: '',
1235 | description: '',
1236 | phone: '+15558675309',
1237 | metadata: {},
1238 | stripe_id: 'cust_1234',
1239 | email: 'x@y.com',
1240 | }
1241 | const server = createServer((req, res) => {
1242 | res.setHeader('connection', 'close')
1243 | server.close()
1244 | t.equal(req.method, 'GET')
1245 | t.equal(req.url, '/v1/whois?org=org%3Ao&include=info')
1246 | res.end(JSON.stringify(response))
1247 | })
1248 | server.listen(port, async () => {
1249 | t.same(await tier.lookupOrg('org:o'), {
1250 | org: 'org:o',
1251 | name: '',
1252 | description: '',
1253 | phone: '+15558675309',
1254 | metadata: {},
1255 | stripe_id: 'cust_1234',
1256 | email: 'x@y.com',
1257 | invoiceSettings: {
1258 | defaultPaymentMethod: '',
1259 | },
1260 | })
1261 | t.end()
1262 | })
1263 | })
1264 |
1265 | t.test('report', t => {
1266 | const expects = [
1267 | {
1268 | org: 'org:o',
1269 | feature: 'feature:f',
1270 | n: 1,
1271 | clobber: false,
1272 | },
1273 | {
1274 | org: 'org:o',
1275 | feature: 'feature:f',
1276 | at: '2022-10-24T21:26:24.438Z',
1277 | n: 10,
1278 | clobber: true,
1279 | },
1280 | ]
1281 |
1282 | const server = createServer((req, res) => {
1283 | res.setHeader('connection', 'close')
1284 | t.equal(req.method, 'POST')
1285 | const chunks: Buffer[] = []
1286 | req.on('data', c => chunks.push(c))
1287 | req.on('end', () => {
1288 | const body = JSON.parse(Buffer.concat(chunks).toString())
1289 | t.same(body, expects.shift())
1290 | res.end(JSON.stringify({ ok: true }))
1291 | if (!expects.length) {
1292 | server.close()
1293 | }
1294 | })
1295 | })
1296 |
1297 | server.listen(port, async () => {
1298 | t.same(await tier.report('org:o', 'feature:f'), { ok: true })
1299 | t.same(
1300 | await tier.report('org:o', 'feature:f', 10, {
1301 | at: new Date('2022-10-24T21:26:24.438Z'),
1302 | clobber: true,
1303 | }),
1304 | { ok: true }
1305 | )
1306 | t.end()
1307 | })
1308 | })
1309 |
1310 | t.test('lookupPaymentMethods', t => {
1311 | const server = createServer((req, res) => {
1312 | res.setHeader('connection', 'close')
1313 | t.equal(req.method, 'GET')
1314 | const u = req.url
1315 | if (!u) {
1316 | throw new Error('did not get a request url??')
1317 | }
1318 | t.equal(u.startsWith('/v1/payment_methods?org=org%3A'), true)
1319 | res.end(
1320 | JSON.stringify({
1321 | org: 'org:' + u.substring(u.length - 1),
1322 | methods: u.endsWith('b') ? ['pm_card_FAKE'] : null,
1323 | })
1324 | )
1325 | })
1326 | t.teardown(() => {
1327 | server.close()
1328 | })
1329 | server.listen(port, async () => {
1330 | t.same(await tier.lookupPaymentMethods('org:o'), {
1331 | org: 'org:o',
1332 | methods: [],
1333 | })
1334 | t.same(await tier.lookupPaymentMethods('org:b'), {
1335 | org: 'org:b',
1336 | methods: ['pm_card_FAKE'],
1337 | })
1338 | t.end()
1339 | })
1340 | })
1341 |
1342 | t.test('can', t => {
1343 | let sawGet = false
1344 | let sawPost = false
1345 | const expects = [
1346 | {
1347 | org: 'org:o',
1348 | feature: 'feature:can',
1349 | n: 1,
1350 | clobber: false,
1351 | },
1352 | {
1353 | org: 'org:o',
1354 | feature: 'feature:can',
1355 | n: 10,
1356 | clobber: false,
1357 | },
1358 | ]
1359 | const server = createServer((req, res) => {
1360 | res.setHeader('connection', 'close')
1361 | if (req.method === 'GET') {
1362 | // looking up limits
1363 | sawGet = true
1364 |
1365 | if (req.url === '/v1/limits?org=org%3Ao') {
1366 | res.end(
1367 | JSON.stringify({
1368 | org: 'org:o',
1369 | usage: [
1370 | {
1371 | feature: 'feature:can',
1372 | used: 341,
1373 | limit: 10000,
1374 | },
1375 | {
1376 | feature: 'feature:cannot',
1377 | used: 234213,
1378 | limit: 10000,
1379 | },
1380 | ],
1381 | })
1382 | )
1383 | } else {
1384 | res.statusCode = 500
1385 | res.end(JSON.stringify({ error: 'blorp' }))
1386 | }
1387 | } else if (req.method === 'POST') {
1388 | // reporting usage
1389 | sawPost = true
1390 | const chunks: Buffer[] = []
1391 | req.on('data', c => chunks.push(c))
1392 | req.on('end', () => {
1393 | const body = JSON.parse(Buffer.concat(chunks).toString())
1394 | t.match(body, expects.shift())
1395 | res.end(JSON.stringify({ ok: true }))
1396 | })
1397 | } else {
1398 | throw new Error('unexpected http method used')
1399 | }
1400 | })
1401 | server.listen(port, async () => {
1402 | const cannot = await tier.can('org:o', 'feature:cannot')
1403 | t.match(cannot, { ok: false, err: undefined })
1404 |
1405 | const err = await tier.can('org:error', 'feature:nope')
1406 | t.match(err, {
1407 | ok: true,
1408 | err: { message: 'Tier request failed', status: 500 },
1409 | })
1410 | t.equal(isTierError(err.err), true)
1411 |
1412 | const can = await tier.can('org:o', 'feature:can')
1413 | t.match(can, { ok: true })
1414 | t.match(await can.report(), { ok: true, err: undefined })
1415 | t.match(await can.report(10), { ok: true, err: undefined })
1416 |
1417 | t.equal(sawPost, true)
1418 | t.equal(sawGet, true)
1419 |
1420 | server.close()
1421 |
1422 | t.end()
1423 | })
1424 | })
1425 |
1426 | t.test(
1427 | 'use abort signal',
1428 | { skip: typeof AbortSignal === 'undefined' && 'no AbortSignal' },
1429 | t => {
1430 | const ac = new AbortController()
1431 | const server = createServer((req, res) => {
1432 | res.setHeader('connection', 'close')
1433 | t.equal(req.method, 'GET')
1434 | t.equal(req.url, '/v1/limits?org=org%3Ao')
1435 | res.write('{"ok":')
1436 | ac.abort()
1437 | setTimeout(() => res.end('true}'), 100)
1438 | })
1439 | server.listen(port, async () => {
1440 | const signal = ac.signal
1441 | await t.rejects(tier.lookupLimits('org:o', { signal }))
1442 | server.close()
1443 | t.end()
1444 | })
1445 | }
1446 | )
1447 |
1448 | t.test('withClock', t => {
1449 | interface ClockResponse {
1450 | id: string
1451 | link: string
1452 | present: Date
1453 | status: string
1454 | }
1455 | const clocks: { [k: string]: ClockResponse } = {
1456 | foo: {
1457 | id: 'id-foo',
1458 | link: 'http://example.com/clock/foo',
1459 | present: new Date(),
1460 | status: 'not ready',
1461 | },
1462 | bar: {
1463 | id: 'id-bar',
1464 | link: 'http://example.com/clock/bar',
1465 | present: new Date(),
1466 | status: 'not ready',
1467 | },
1468 | }
1469 |
1470 | t.teardown(() => {
1471 | server.close()
1472 | })
1473 | const server = createServer((req, res) => {
1474 | t.ok(req.url?.startsWith('/v1/clock'))
1475 | res.setHeader('connection', 'close')
1476 | const url = actualRequestUrl(req)
1477 | if (!url) throw new Error('could not discern url')
1478 | if (req.method === 'GET') {
1479 | const id = url.searchParams.get('id')
1480 | if (!id) throw new Error('did not send clockID in GET')
1481 | if (id !== 'id-foo' && id !== 'id-bar') {
1482 | throw new Error('unknown clockID: ' + id)
1483 | }
1484 | const clock = id === 'id-foo' ? clocks.foo : clocks.bar
1485 | clock.status =
1486 | clock.status === 'not ready'
1487 | ? 'waiting 1'
1488 | : clock.status === 'waiting 1'
1489 | ? 'waiting 2'
1490 | : clock.status === 'waiting 2'
1491 | ? 'waiting 3'
1492 | : 'ready'
1493 | res.end(JSON.stringify(clock))
1494 | } else if (req.method === 'POST') {
1495 | // either starting or syncing a clock
1496 | const b: Buffer[] = []
1497 | req.on('data', c => b.push(c))
1498 | req.on('end', () => {
1499 | const body = JSON.parse(Buffer.concat(b).toString())
1500 | t.type(body.present, 'string')
1501 | const p = new Date(body.present)
1502 | if (body.id) {
1503 | t.equal(body.id, req.headers['tier-clock'])
1504 | if (body.id !== 'id-foo' && body.id !== 'id-bar') {
1505 | throw new Error('unknown clockID: ' + body.id)
1506 | }
1507 | const clock = body.id === 'id-foo' ? clocks.foo : clocks.bar
1508 | clock.present = p
1509 | clock.status = 'not ready'
1510 | res.end(JSON.stringify(clock))
1511 | } else if (body.name) {
1512 | const name = body.name
1513 | if (name !== 'foo' && name !== 'bar') {
1514 | throw new Error('invalid clock name: ' + name)
1515 | }
1516 | const clock = clocks[name as 'foo' | 'bar']
1517 | clock.present = p
1518 | clock.status = 'not ready'
1519 | res.end(JSON.stringify(clock))
1520 | } else {
1521 | throw new Error('invalid post request: ' + JSON.stringify(body))
1522 | }
1523 | })
1524 | } else {
1525 | throw new Error('invalid request method: ' + req.method)
1526 | }
1527 | })
1528 |
1529 | server.listen(port, async () => {
1530 | const ac =
1531 | typeof AbortController !== 'undefined' ? new AbortController() : null
1532 | const noclock = await tier.fromEnv()
1533 | const noclock2 = await tier.fromEnv({ signal: ac?.signal })
1534 | const foo = await tier.withClock('foo', new Date('1979-07-01'))
1535 | const bar = await tier.withClock('bar', new Date('2020-07-01'), {
1536 | signal: ac?.signal,
1537 | })
1538 | //@ts-expect-error
1539 | t.throws(() => noclock.advance(new Date()))
1540 | //@ts-expect-error
1541 | t.throws(() => noclock2.advance(new Date()))
1542 | t.equal(
1543 | clocks.foo.present.toISOString(),
1544 | new Date('1979-07-01').toISOString()
1545 | )
1546 | t.equal(
1547 | clocks.bar.present.toISOString(),
1548 | new Date('2020-07-01').toISOString()
1549 | )
1550 | await foo.advance(new Date('2020-01-01'))
1551 | t.equal(
1552 | clocks.foo.present.toISOString(),
1553 | new Date('2020-01-01').toISOString()
1554 | )
1555 | await bar.advance(new Date('2025-02-03'))
1556 | t.equal(
1557 | clocks.bar.present.toISOString(),
1558 | new Date('2025-02-03').toISOString()
1559 | )
1560 | if (ac) {
1561 | const p = bar.advance(new Date('2038-12-12'))
1562 | ac.abort()
1563 | await t.rejects(p)
1564 | t.equal(
1565 | clocks.bar.present.toISOString(),
1566 | new Date('2025-02-03').toISOString()
1567 | )
1568 | }
1569 | t.end()
1570 | })
1571 | })
1572 |
1573 | t.test('called init', async () => t.equal(initCalled, true))
1574 |
1575 | t.test('warnings', t => {
1576 | // because we had a test for it earlier
1577 | t.same(warnings, [
1578 | 'pullLatest is deprecated, and will be removed in the next version',
1579 | ])
1580 | const server = createServer((_req, res) => {
1581 | res.setHeader('connection', 'close')
1582 | res.end('{"usage":[],"effective":0,"plans":{}}')
1583 | })
1584 | t.teardown(() => {
1585 | server.close()
1586 | })
1587 | server.listen(port, async () => {
1588 | await tier.pullLatest()
1589 | await tier.limit('org:o', 'feature:foo')
1590 | await tier.phase('org:o')
1591 | await tier.limits('org:o')
1592 | await tier.limit('org:o', 'feature:foo')
1593 | await tier.phase('org:o')
1594 | await tier.limits('org:o')
1595 | t.same(warnings, [
1596 | 'pullLatest is deprecated, and will be removed in the next version',
1597 | 'Tier.limit is deprecated. Please use Tier.lookupLimit instead.',
1598 | 'Tier.phase is deprecated. Please use Tier.lookupPhase instead.',
1599 | 'Tier.limits is deprecated. Please use Tier.lookupLimits instead.',
1600 | ])
1601 | t.end()
1602 | })
1603 | })
1604 |
--------------------------------------------------------------------------------
/test/init.ts:
--------------------------------------------------------------------------------
1 | import child_process, { ChildProcess } from 'child_process'
2 | import t from 'tap'
3 | const port = 10000 + (process.pid % 10000)
4 | const SPAWN_CALLS: any[][] = []
5 | let SPAWN_FAIL = false
6 | let SPAWN_PROC: ChildProcess | undefined
7 | const mock = {
8 | child_process: {
9 | ...child_process,
10 | spawn: (cmd: any, args: any, opts: any) => {
11 | SPAWN_CALLS.push([cmd, ...args])
12 | const c = SPAWN_FAIL ? 'no command by this name lol' : 'cat'
13 | opts.stdio = 'pipe'
14 | const proc = child_process.spawn(c, [], opts)
15 | if (!SPAWN_FAIL) {
16 | proc.stdin.write('this is fine\n')
17 | }
18 | SPAWN_PROC = proc
19 | proc.on('exit', () => {
20 | SPAWN_PROC = undefined
21 | })
22 | return proc
23 | },
24 | },
25 | }
26 | const { init, getClient } = t.mock('../dist/cjs/get-client.js', mock)
27 | const tier = t.mock('../', mock).default
28 |
29 | t.afterEach(async () => {
30 | SPAWN_FAIL = false
31 | delete process.env.TIER_LIVE
32 | delete process.env.TIER_BASE_URL
33 | SPAWN_CALLS.length = 0
34 | // always just kill the sidecar process between tests
35 | if (SPAWN_PROC) {
36 | // @ts-ignore
37 | let t: Timer | undefined = undefined
38 | const p = new Promise(res => {
39 | t = setTimeout(() => res(), 200)
40 | if (SPAWN_PROC) {
41 | SPAWN_PROC.on('exit', () => res())
42 | SPAWN_PROC.kill('SIGKILL')
43 | }
44 | })
45 | // @ts-ignore
46 | SPAWN_PROC.kill('SIGKILL')
47 | await p
48 | clearTimeout(t)
49 | }
50 | })
51 |
52 | t.test('reject API calls if init fails', async t => {
53 | SPAWN_FAIL = true
54 | await t.rejects(tier.limits('org:o'))
55 | await t.rejects(getClient())
56 | // tries 2 times
57 | t.same(SPAWN_CALLS, [
58 | ['tier', 'serve', '--addr', `127.0.0.1:${port}`],
59 | ['tier', 'serve', '--addr', `127.0.0.1:${port}`],
60 | ])
61 | })
62 |
63 | t.test('reject if TIER_BASE_URL gets unset', async t => {
64 | await init()
65 | process.env.TIER_BASE_URL = ''
66 | await t.rejects(getClient())
67 | t.same(SPAWN_CALLS, [['tier', 'serve', '--addr', `127.0.0.1:${port}`]])
68 | })
69 |
70 | t.test('live mode when TIER_LIVE is set', async t => {
71 | process.env.TIER_LIVE = '1'
72 | const c = await getClient()
73 | t.ok(c, 'got a client')
74 | t.equal(c.baseURL, `http://127.0.0.1:${port}`)
75 | t.same(SPAWN_CALLS, [
76 | ['tier', '--live', 'serve', '--addr', `127.0.0.1:${port}`],
77 | ])
78 | })
79 |
80 | t.test('only init one time in parallel', async t => {
81 | await Promise.all([init(), init()])
82 | t.same(SPAWN_CALLS, [['tier', 'serve', '--addr', `127.0.0.1:${port}`]])
83 | })
84 |
85 | t.test('only init one time ever', async t => {
86 | await init()
87 | await init()
88 | t.same(SPAWN_CALLS, [['tier', 'serve', '--addr', `127.0.0.1:${port}`]])
89 | })
90 |
91 | t.test('no init if env says running', async t => {
92 | process.env.TIER_BASE_URL = `http://127.0.0.1:${port}`
93 | await init()
94 | t.same(SPAWN_CALLS, [])
95 | })
96 |
97 | t.test('debug runs sidecar in debug mode', async t => {
98 | process.env.TIER_DEBUG = '1'
99 | const logs: any[][] = []
100 | const { error } = console
101 | t.teardown(() => {
102 | console.error = error
103 | delete process.env.TIER_DEBUG
104 | })
105 | console.error = (...args: any[]) => logs.push(args)
106 | const { init } = t.mock('../dist/cjs/get-client.js', mock)
107 | await init()
108 | t.match(logs, [
109 | ['tier:', ['-v', 'serve', '--addr', /^127\.0\.0\.1:\d+$/]],
110 | ['tier:', 'started sidecar', Number],
111 | ])
112 | })
113 |
114 | t.test('default to api.tier.run if apiKey is set', async t => {
115 | delete process.env.TIER_BASE_URL
116 | delete process.env.TIER_API_KEY
117 | const c = await getClient({ apiKey: 'param api key' })
118 | t.equal(c.baseURL, 'https://api.tier.run')
119 | t.equal(c.apiKey, 'param api key')
120 | process.env.TIER_API_KEY = 'env api key'
121 | const d = await getClient()
122 | t.equal(d.baseURL, 'https://api.tier.run')
123 | t.equal(d.apiKey, 'env api key')
124 | const e = await getClient({ apiKey: 'param 2 api key' })
125 | t.equal(e.baseURL, 'https://api.tier.run')
126 | t.equal(e.apiKey, 'param 2 api key')
127 | t.end()
128 | })
129 |
--------------------------------------------------------------------------------
/test/is.ts:
--------------------------------------------------------------------------------
1 | import t from 'tap'
2 | import {
3 | isAggregate,
4 | isFeatureDefinition,
5 | isFeatureName,
6 | isFeatureNameVersioned,
7 | isFeatures,
8 | isFeatureTier,
9 | isInterval,
10 | isMode,
11 | isModel,
12 | isOrgName,
13 | isPlan,
14 | isPlanName,
15 | validateFeatureDefinition,
16 | validateFeatureTier,
17 | validateModel,
18 | validatePlan,
19 | } from '../'
20 |
21 | t.test('isMode', t => {
22 | t.ok(isMode('graduated'))
23 | t.ok(isMode('volume'))
24 | t.notOk(isMode(true))
25 | t.notOk(isMode('true'))
26 | t.notOk(isMode(null))
27 | t.notOk(isMode(false))
28 | t.end()
29 | })
30 |
31 | t.test('isAggregate', t => {
32 | t.ok(isAggregate('sum'))
33 | t.ok(isAggregate('max'))
34 | t.ok(isAggregate('last'))
35 | t.ok(isAggregate('perpetual'))
36 | t.notOk(isAggregate(true))
37 | t.notOk(isAggregate('true'))
38 | t.notOk(isAggregate(null))
39 | t.notOk(isAggregate(false))
40 | t.end()
41 | })
42 |
43 | t.test('isInterval', t => {
44 | t.ok(isInterval('@daily'))
45 | t.ok(isInterval('@weekly'))
46 | t.ok(isInterval('@monthly'))
47 | t.ok(isInterval('@yearly'))
48 | t.notOk(isInterval(true))
49 | t.notOk(isInterval('true'))
50 | t.notOk(isInterval(null))
51 | t.notOk(isInterval(false))
52 | t.end()
53 | })
54 |
55 | t.test('isFeatureTier', t => {
56 | t.ok(isFeatureTier({}))
57 | t.ok(isFeatureTier({ base: 123 }))
58 | t.notOk(isFeatureTier({ base: 1.3 }))
59 | t.ok(isFeatureTier({ price: 1 }))
60 | t.ok(isFeatureTier({ price: 1.2 }))
61 | t.ok(isFeatureTier({ upto: 2 }))
62 | t.notOk(isFeatureTier(null))
63 | t.notOk(isFeatureTier(true))
64 | t.notOk(isFeatureTier({ base: 'hello' }))
65 | t.notOk(isFeatureTier({ base: -1.2 }))
66 | t.notOk(isFeatureTier({ base: -1 }))
67 | t.notOk(isFeatureTier({ price: 'hello' }))
68 | t.notOk(isFeatureTier({ price: -1.2 }))
69 | t.notOk(isFeatureTier({ upto: 'hello' }))
70 | t.notOk(isFeatureTier({ upto: 1.2 }))
71 | t.notOk(isFeatureTier({ upto: 0 }))
72 | t.notOk(isFeatureTier({ upto: -1 }))
73 | t.notOk(isFeatureTier({ other: 'thing' }))
74 | t.end()
75 | })
76 |
77 | t.test('validateFeatureTier', t => {
78 | const cases = [
79 | {},
80 | { base: 123 },
81 | { base: 1.3 },
82 | { base: -1 },
83 | { base: 0 },
84 | { price: 1 },
85 | { price: 1.2 },
86 | { price: -1.2 },
87 | { upto: -2 },
88 | { upto: 0 },
89 | null,
90 | true,
91 | { base: 'hello' },
92 | { price: 'hello' },
93 | { price: 1.2 },
94 | { upto: 'hello' },
95 | { upto: 1.2 },
96 | { base: -1.2 },
97 | { other: 'thing' },
98 | ]
99 | t.plan(cases.length)
100 | for (const c of cases) {
101 | let err: any
102 | try {
103 | validateFeatureTier(c)
104 | } catch (er) {
105 | err = er
106 | }
107 | t.matchSnapshot([err, c])
108 | }
109 | })
110 |
111 | t.test('isFeatures', t => {
112 | t.ok(isFeatures('feature:foo@plan:blah@1'))
113 | // XXX: change once we have a 'features' section in pj, perhaps
114 | t.notOk(isFeatures('feature:foo@123'))
115 | t.ok(isFeatures('plan:foo@1'))
116 | t.notOk(isFeatures('feature:foo'))
117 | t.notOk(isFeatures('plan:foo'))
118 | t.notOk(isFeatures(null))
119 | t.notOk(isFeatures(''))
120 | t.notOk(isFeatures(true))
121 | t.end()
122 | })
123 |
124 | t.test('isFeatureNameVersioned', t => {
125 | t.ok(isFeatureNameVersioned('feature:foo@plan:blah@1'))
126 | // XXX: change once we have a 'features' section in pj, perhaps
127 | t.notOk(isFeatureNameVersioned('feature:foo@123'))
128 | t.notOk(isFeatureNameVersioned('plan:foo@1'))
129 | t.notOk(isFeatureNameVersioned('feature:foo'))
130 | t.notOk(isFeatureNameVersioned('plan:foo'))
131 | t.notOk(isFeatureNameVersioned(null))
132 | t.notOk(isFeatureNameVersioned(''))
133 | t.notOk(isFeatureNameVersioned(true))
134 | t.end()
135 | })
136 |
137 | t.test('isFeatureName', t => {
138 | t.ok(isFeatureName('feature:foo'))
139 | t.notOk(isFeatureName('feature:'))
140 | t.notOk(isFeatureName('feature:foo@plan:blah@1'))
141 | t.notOk(isFeatureName('feature:foo@123'))
142 | t.notOk(isFeatureName('plan:foo@1'))
143 | t.notOk(isFeatureName('plan:foo'))
144 | t.notOk(isFeatureName(null))
145 | t.notOk(isFeatureName(''))
146 | t.notOk(isFeatureName(true))
147 | t.end()
148 | })
149 |
150 | t.test('isPlanName', t => {
151 | t.ok(isPlanName('plan:foo@1'))
152 | t.notOk(isPlanName('feature:foo'))
153 | t.notOk(isPlanName('feature:foo@plan:blah@1'))
154 | t.notOk(isPlanName('feature:foo@123'))
155 | t.notOk(isPlanName('plan:foo'))
156 | t.notOk(isPlanName(null))
157 | t.notOk(isPlanName(''))
158 | t.notOk(isPlanName(true))
159 | t.notOk(isPlan({ currency: 'this is too long' }))
160 | t.notOk(isPlan({ currency: 'USD' }), 'currency should be lowercase')
161 | t.notOk(isPlan({ currency: 'eu' }), 'currency should 3 chars')
162 | t.end()
163 | })
164 |
165 | t.test('isOrgName', t => {
166 | t.ok(isOrgName('org:whatever'))
167 | t.notOk(isOrgName('org:'))
168 | t.notOk(isOrgName('org'))
169 | t.notOk(isOrgName({ toString: () => 'org:x' }))
170 | t.notOk(isOrgName(true))
171 | t.notOk(isOrgName(null))
172 | t.end()
173 | })
174 |
175 | t.test('isFeatureDefinition', t => {
176 | t.ok(isFeatureDefinition({}))
177 | t.ok(isFeatureDefinition({ tiers: [] }))
178 | t.ok(isFeatureDefinition({ title: 'x', base: 1, aggregate: 'sum' }))
179 | t.ok(
180 | isFeatureDefinition({
181 | base: 1,
182 | divide: { by: 100 },
183 | })
184 | )
185 | t.ok(
186 | isFeatureDefinition({
187 | base: 1,
188 | divide: { by: 100, rounding: 'up' },
189 | })
190 | )
191 | t.ok(
192 | isFeatureDefinition({
193 | tiers: [{ price: 1 }],
194 | divide: { by: 100, rounding: 'up' },
195 | })
196 | )
197 | t.ok(
198 | isFeatureDefinition({
199 | tiers: [{ price: 1, upto: 10 }],
200 | divide: { by: 100, rounding: 'up' },
201 | })
202 | )
203 | t.notOk(
204 | isFeatureDefinition({
205 | tiers: [{ base: 1 }],
206 | divide: { by: 100, rounding: 'up' },
207 | })
208 | )
209 | t.notOk(
210 | isFeatureDefinition({
211 | tiers: [{ base: 1, upto: 10 }],
212 | divide: { by: 100, rounding: 'up' },
213 | })
214 | )
215 | t.notOk(
216 | isFeatureDefinition({
217 | tiers: [{ base: 1, price: 10 }],
218 | divide: { by: 100, rounding: 'up' },
219 | })
220 | )
221 | t.notOk(
222 | isFeatureDefinition({
223 | tiers: [{ upto: 1, price: 1 }, {}],
224 | divide: { by: 100, rounding: 'up' },
225 | })
226 | )
227 | t.notOk(
228 | isFeatureDefinition({
229 | base: 1,
230 | divide: true,
231 | })
232 | )
233 | t.notOk(
234 | isFeatureDefinition({
235 | base: 1,
236 | divide: { by: 100.222, rounding: 'up' },
237 | })
238 | )
239 | t.notOk(
240 | isFeatureDefinition({
241 | base: 1,
242 | divide: { by: 100, rounding: 12 },
243 | })
244 | )
245 | t.notOk(
246 | isFeatureDefinition({
247 | base: 1,
248 | divide: { by: 100, rounding: 'down' },
249 | })
250 | )
251 | t.ok(
252 | isFeatureDefinition({
253 | title: 'x',
254 | mode: 'graduated',
255 | tiers: [{}],
256 | })
257 | )
258 | t.notOk(isFeatureDefinition({ base: -1 }))
259 | t.ok(isFeatureDefinition({ base: 1.2 }))
260 | t.ok(isFeatureDefinition({ base: 0 }))
261 |
262 | // cannot have base and tiers together
263 | t.notOk(isFeatureDefinition({ title: 'x', base: 1, tiers: [] }))
264 | t.notOk(
265 | isFeatureDefinition({
266 | mode: 'not a valid mode',
267 | })
268 | )
269 | t.notOk(
270 | isFeatureDefinition({
271 | tiers: 'tiers not an array',
272 | })
273 | )
274 | t.notOk(
275 | isFeatureDefinition({
276 | tiers: [{ base: 'tier invalid' }],
277 | })
278 | )
279 | t.notOk(isFeatureDefinition({ base: 123, aggregate: 'yolo' }))
280 |
281 | // cannot have any other fields
282 | t.notOk(isFeatureDefinition({ heloo: 'world' }))
283 | t.end()
284 | })
285 |
286 | t.test('validateFeatureDefinition', t => {
287 | const cases = [
288 | null,
289 | true,
290 | {},
291 | { base: 100, divide: true },
292 | { base: 100, divide: { by: 100 } },
293 | { base: 100, divide: { by: 100.123 } },
294 | { base: 100, divide: { by: 100, rounding: 'up' } },
295 | { base: 100, divide: { by: 100, rounding: 'circle' } },
296 | { base: 100, divide: { rounding: 'circle' } },
297 | {
298 | tiers: [],
299 | divide: { by: 100, rounding: 'up' },
300 | },
301 | {
302 | tiers: [{ upto: 1, price: 1 }],
303 | divide: { by: 100, rounding: 'up' },
304 | },
305 | {
306 | tiers: [{ upto: 1, price: 1 }, {}],
307 | divide: { by: 100, rounding: 'up' },
308 | },
309 | { tiers: [] },
310 | { title: 'x', base: 1, aggregate: 'sum' },
311 | { title: { not: 'a string' } },
312 | { base: 1.2 },
313 | { base: -1 },
314 | { base: 0 },
315 | {
316 | title: 'x',
317 | mode: 'graduated',
318 | tiers: [{}],
319 | },
320 | { title: 'x', base: 1, tiers: [] },
321 | {
322 | mode: 'not a valid mode',
323 | },
324 | {
325 | tiers: 'tiers not an array',
326 | },
327 | {
328 | tiers: [{ base: 'tier invalid' }],
329 | },
330 | { base: 123, aggregate: 'yolo' },
331 | { heloo: 'world' },
332 | { tiers: { not: 'an array' } },
333 | { tiers: [{}, { x: 1 }] },
334 | { base: 1.2 },
335 | ]
336 | t.plan(cases.length)
337 | for (const c of cases) {
338 | let err: any
339 | try {
340 | validateFeatureDefinition(c)
341 | } catch (er) {
342 | err = er
343 | }
344 | t.matchSnapshot([err, c])
345 | }
346 | })
347 |
348 | t.test('isPlan', t => {
349 | t.notOk(isPlan(null))
350 | t.notOk(isPlan(true))
351 | t.notOk(
352 | isPlan({
353 | title: { not: 'a string' },
354 | })
355 | )
356 | t.notOk(
357 | isPlan({
358 | features: {
359 | 'not a feature name': {},
360 | },
361 | })
362 | )
363 | t.ok(isPlan({}))
364 | t.ok(
365 | isPlan({
366 | features: {
367 | 'feature:name': {},
368 | },
369 | })
370 | )
371 | t.notOk(
372 | isPlan({
373 | currency: { not: 'a currency string' },
374 | })
375 | )
376 | t.ok(
377 | isPlan({
378 | currency: 'usd',
379 | })
380 | )
381 | t.notOk(
382 | isPlan({
383 | interval: { not: 'an interval string' },
384 | })
385 | )
386 | t.notOk(
387 | isPlan({
388 | interval: 'not an interval string',
389 | })
390 | )
391 | t.ok(
392 | isPlan({
393 | interval: '@monthly',
394 | })
395 | )
396 | t.notOk(isPlan({ another: 'thing' }), 'cannot have extra fields')
397 | t.end()
398 | })
399 |
400 | t.test('validatePlan', t => {
401 | const cases = [
402 | null,
403 | true,
404 | { title: { not: 'a string' } },
405 | { features: null },
406 | { features: 'not an object' },
407 | { features: { 'not a feature name': {} } },
408 | {},
409 | { features: { 'feature:name': {} } },
410 | { features: { 'feature:name': { tiers: [{ upto: 1 }, { x: true }] } } },
411 | { currency: { not: 'a currency string' } },
412 | { currency: 'usd' },
413 | { interval: { not: 'an interval string' } },
414 | { interval: 'not an interval string' },
415 | { interval: '@monthly' },
416 | { another: 'thing' },
417 | ]
418 | for (const c of cases) {
419 | let err: any
420 | try {
421 | validatePlan(c)
422 | } catch (er) {
423 | err = er
424 | }
425 | t.matchSnapshot([err, c])
426 | }
427 | t.plan(cases.length)
428 | })
429 |
430 | t.test('isModel', t => {
431 | t.notOk(isModel(null))
432 | t.notOk(isModel(true))
433 | t.notOk(isModel({}))
434 | t.ok(isModel({ plans: {} }))
435 | t.ok(isModel({ plans: { 'plan:p@0': {} } }))
436 | t.notOk(isModel({ plans: { 'not a plan name': {} } }))
437 | t.notOk(
438 | isModel({
439 | plans: {
440 | 'plan:notaplan@0': {
441 | features: {
442 | 'not a feature name': {},
443 | },
444 | },
445 | },
446 | })
447 | )
448 | t.notOk(isModel({ plans: {}, other: 'stuff' }), 'cannot have other stuff')
449 | t.end()
450 | })
451 |
452 | t.test('validateModel', t => {
453 | const cases = [
454 | null,
455 | true,
456 | {},
457 | { plans: {} },
458 | { plans: { 'plan:p@0': {} } },
459 | { plans: { 'not a plan name': {} } },
460 | {
461 | plans: {
462 | 'plan:notaplan@0': {
463 | features: {
464 | 'not a feature name': {},
465 | },
466 | },
467 | },
468 | },
469 | { plans: {}, other: 'stuff' },
470 | {
471 | plans: {
472 | 'plan:x@1': {
473 | features: { 'feature:name': { tiers: [{ upto: 1 }, { x: true }] } },
474 | },
475 | },
476 | },
477 | ]
478 | t.plan(cases.length)
479 | for (const c of cases) {
480 | let err: any
481 | try {
482 | validateModel(c)
483 | } catch (er) {
484 | err = er
485 | }
486 | t.matchSnapshot([err, c])
487 | }
488 | t.end()
489 | })
490 |
--------------------------------------------------------------------------------
/tsconfig-base.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["./test", "./tap-snapshots"],
3 | "include": ["src/**/*.ts"],
4 | "compilerOptions": {
5 | "declaration": true,
6 | "sourceMap": true,
7 | "declarationMap": true,
8 | "inlineSources": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "isolatedModules": true,
11 | "esModuleInterop": true,
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "strict": true,
15 | "skipLibCheck": true,
16 | "allowSyntheticDefaultImports": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig-cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig-base.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "outDir": "dist/cjs",
6 | "target": "es2015"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig-esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig-base.json",
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "outDir": "dist/mjs",
6 | "target": "esnext"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "navigationLinks": {
3 | "tier.run": "https://www.tier.run/"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------