Files
modeling-app/src/machines/billingMachine.ts
Zookeeper Lee bfa2f67393 Add a 1s client side rate limit / cache to billing requests (#7067)
* Add a 1s client side rate limit / cache to billing requests

* Stop using err; use isErr instead
2025-05-19 14:11:33 -04:00

219 lines
5.8 KiB
TypeScript

import type { Models } from '@kittycad/lib'
import crossPlatformFetch from '@src/lib/crossPlatformFetch'
import type { ActorRefFrom } from 'xstate'
import { assign, fromPromise, setup } from 'xstate'
import { isErr } from '@src/lib/trap'
const _TIME_1_SECOND = 1000
export enum BillingState {
Updating = 'updating',
Waiting = 'waiting',
}
export enum BillingTransition {
Update = 'update',
Wait = 'wait',
}
// It's nice to be explicit if we are an Organization, Pro, Free.
// @kittycad/lib offers some types around this, but they aren't as...
// homogeneous: Models['ZooProductSubscriptions_type'], and
// Models['Org_type'].
export enum Tier {
Free = 'free',
Pro = 'pro',
Organization = 'organization',
Unknown = 'unknown',
}
export type OrgOrError = Models['Org_type'] | number | Error
export type SubscriptionsOrError =
| Models['ZooProductSubscriptions_type']
| number
| Error
export type TierBasedOn = {
orgOrError: OrgOrError
subscriptionsOrError: SubscriptionsOrError
}
export interface BillingContext {
credits: undefined | number
allowance: undefined | number
error: undefined | Error
urlUserService: string
tier: undefined | Tier
subscriptionsOrError: undefined | SubscriptionsOrError
lastFetch: undefined | Date
}
export interface BillingUpdateEvent {
type: BillingTransition.Update
apiToken: string
}
export const BILLING_CONTEXT_DEFAULTS: BillingContext = Object.freeze({
credits: undefined,
allowance: undefined,
error: undefined,
tier: undefined,
subscriptionsOrError: undefined,
urlUserService: '',
lastFetch: undefined,
})
const toTierFrom = (args: TierBasedOn): Tier => {
if (typeof args.orgOrError !== 'number' && !isErr(args.orgOrError)) {
return Tier.Organization
} else if (
typeof args.subscriptionsOrError !== 'number' &&
!isErr(args.subscriptionsOrError)
) {
const subscriptions: Models['ZooProductSubscriptions_type'] =
args.subscriptionsOrError
if (subscriptions.modeling_app.name === 'pro') {
return Tier.Pro
} else {
return Tier.Free
}
}
return Tier.Unknown
}
export const billingMachine = setup({
types: {
context: {} as BillingContext,
input: {} as BillingContext,
events: {} as BillingUpdateEvent,
},
actors: {
[BillingTransition.Update]: fromPromise(
async ({
input,
}: { input: { context: BillingContext; event: BillingUpdateEvent } }) => {
// Rate limit on the client side to 1 request per second.
if (
input.context.lastFetch &&
Date.now() - input.context.lastFetch.getTime() < _TIME_1_SECOND
) {
return input.context
}
const billingOrError: Models['CustomerBalance_type'] | number | Error =
await crossPlatformFetch(
`${input.context.urlUserService}/user/payment/balance`,
{ method: 'GET' },
input.event.apiToken
)
if (typeof billingOrError === 'number' || isErr(billingOrError)) {
return Promise.reject(billingOrError)
}
const billing: Models['CustomerBalance_type'] = billingOrError
const subscriptionsOrError:
| Models['ZooProductSubscriptions_type']
| number
| Error = await crossPlatformFetch(
`${input.context.urlUserService}/user/payment/subscriptions`,
{ method: 'GET' },
input.event.apiToken
)
const orgOrError: Models['Org_type'] | number | Error =
await crossPlatformFetch(
`${input.context.urlUserService}/org`,
{ method: 'GET' },
input.event.apiToken
)
const tier = toTierFrom({
orgOrError,
subscriptionsOrError,
})
let credits =
Number(billing.monthly_api_credits_remaining) +
Number(billing.stable_api_credits_remaining)
let allowance = undefined
switch (tier) {
case Tier.Organization:
case Tier.Pro:
credits = Infinity
break
case Tier.Free:
// TS too dumb Tier.Free has the same logic
if (
typeof subscriptionsOrError !== 'number' &&
!isErr(subscriptionsOrError)
) {
allowance = Number(
subscriptionsOrError.modeling_app
.monthly_pay_as_you_go_api_credits
)
}
break
case Tier.Unknown:
break
default:
const _exh: never = tier
}
// If nothing matches, we show a credit total.
return {
error: undefined,
tier,
subscriptionsOrError,
credits,
allowance,
lastFetch: new Date(),
}
}
),
},
}).createMachine({
initial: BillingState.Waiting,
context: (args) => args.input,
states: {
[BillingState.Waiting]: {
on: {
[BillingTransition.Update]: {
target: BillingState.Updating,
},
},
},
[BillingState.Updating]: {
invoke: {
src: BillingTransition.Update,
input: (args: {
context: BillingContext
event: BillingUpdateEvent
}) => ({
context: args.context,
event: args.event,
}),
onDone: [
{
target: BillingState.Waiting,
actions: assign(({ event }) => event.output),
},
],
// If request failed for billing, go back into waiting state,
// and signal to the user there's an issue regarding the service.
onError: [
{
target: BillingState.Waiting,
// Yep, this is hard to follow. XState, why!
actions: assign({ error: ({ event }) => event.error as Error }),
},
],
},
},
},
})
export type BillingActor = ActorRefFrom<typeof billingMachine>