├── .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 | --------------------------------------------------------------------------------