diff --git a/README.md b/README.md index 99f5732..dd3dfeb 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,14 @@ console.log(btcRange.toString({ preferredUnit: "sat" })) // "100,000 sats - 1,00 ## Other features +### Supabase Integration + +For Supabase/PostgREST applications, see [`@thesis-co/cent-supabase`](./packages/cent-supabase) which automatically handles `DECIMAL`/`NUMERIC` columns, preventing JavaScript precision loss. + +### Zod Integration + +For input validation and parsing, see [`@thesis-co/cent-zod`](./packages/cent-zod) which provides Zod schemas for all `cent` types. + ### Currency support `cent` includes comprehensive currency metadata for accurate formatting: diff --git a/packages/cent-react/COPYRIGHT b/packages/cent-react/COPYRIGHT new file mode 100644 index 0000000..b9ae061 --- /dev/null +++ b/packages/cent-react/COPYRIGHT @@ -0,0 +1,3 @@ +Copyright (c) 2026 Thesis, Inc. + +All rights reserved. diff --git a/packages/cent-react/LICENSE b/packages/cent-react/LICENSE new file mode 100644 index 0000000..669a169 --- /dev/null +++ b/packages/cent-react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thesis, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cent-react/README.md b/packages/cent-react/README.md new file mode 100644 index 0000000..7db3362 --- /dev/null +++ b/packages/cent-react/README.md @@ -0,0 +1,241 @@ +# @thesis-co/cent-react + +React bindings for [@thesis-co/cent](https://www.npmjs.com/package/@thesis-co/cent) - display, input, and manage money values with ease. + +## Installation + +```bash +npm install @thesis-co/cent @thesis-co/cent-react +``` + +## Quick Start + +### Display Money + +```tsx +import { MoneyDisplay } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +// Basic usage + +// → "$1,234.56" + +// Compact notation + +// → "$1.5M" + +// Crypto with satoshis + +// → "100,000 sats" + +// Locale formatting + +// → "1.234,56 €" + +// Null handling with placeholder + +// → "—" +``` + +### Custom Parts Rendering + +```tsx + + {({ parts, isNegative }) => ( + + {parts.map((part, i) => ( + + {part.value} + + ))} + + )} + +``` + +### Money Input + +```tsx +import { MoneyInput } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +function PaymentForm() { + const [amount, setAmount] = useState(null); + + return ( + setAmount(e.target.value)} + currency="USD" + min="$1" + max="$10000" + placeholder="Enter amount" + /> + ); +} +``` + +### With react-hook-form + +```tsx +import { Controller, useForm } from 'react-hook-form'; +import { MoneyInput } from '@thesis-co/cent-react'; + +function CheckoutForm() { + const { control, handleSubmit } = useForm(); + + return ( +
+ ( + + )} + /> + + ); +} +``` + +### useMoney Hook + +```tsx +import { useMoney, MoneyDisplay } from '@thesis-co/cent-react'; + +function TipCalculator() { + const bill = useMoney({ currency: 'USD' }); + const tip = bill.money?.multiply(0.18) ?? null; + + return ( +
+ + {bill.error && {bill.error.message}} + +

Tip (18%):

+

Total:

+
+ ); +} +``` + +### MoneyProvider + +Set default configuration for all descendant components: + +```tsx +import { MoneyProvider } from '@thesis-co/cent-react'; + +function App() { + return ( + + + + ); +} +``` + +### useExchangeRate Hook + +```tsx +import { useExchangeRate, MoneyDisplay } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +function CurrencyConverter() { + const [usd, setUsd] = useState(Money.zero('USD')); + + const { convert, isLoading, isStale, refetch } = useExchangeRate({ + from: 'USD', + to: 'EUR', + pollInterval: 60000, // Refresh every minute + staleThreshold: 300000, // Stale after 5 minutes + }); + + const eur = convert(usd); + + return ( +
+ setUsd(e.target.value)} currency="USD" /> + + {isLoading ? ( + Loading... + ) : ( + + )} + + {isStale && ( + + )} +
+ ); +} +``` + +**Note:** `useExchangeRate` requires an `exchangeRateResolver` to be provided via `MoneyProvider`: + +```tsx + { + const response = await fetch(`/api/rates/${from}/${to}`); + const data = await response.json(); + return new ExchangeRate(from, to, data.rate); + }} +> + + +``` + +### MoneyDiff + +Display the difference between two money values: + +```tsx +import { MoneyDiff } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +// Basic difference + +// → "+$20.00" + +// With percentage change + +// → "+$20.00 (+20.00%)" + +// Custom rendering + + {({ direction, formatted }) => ( + + {formatted.difference} + + )} + +``` + +## API Reference + +### Components + +| Component | Description | +|-----------|-------------| +| `MoneyDisplay` | Display formatted money values | +| `MoneyInput` | Controlled input for money values | +| `MoneyDiff` | Display difference between two values | +| `MoneyProvider` | Context provider for default configuration | + +### Hooks + +| Hook | Description | +|------|-------------| +| `useMoney` | Manage money state with validation | +| `useExchangeRate` | Fetch and manage exchange rates | +| `useMoneyConfig` | Access MoneyProvider context | + +## Requirements + +- React 17.0.0 or later +- @thesis-co/cent 0.0.5 or later diff --git a/packages/cent-react/jest.config.js b/packages/cent-react/jest.config.js new file mode 100644 index 0000000..798a558 --- /dev/null +++ b/packages/cent-react/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testMatch: ['**/test/**/*.test.ts', '**/test/**/*.test.tsx'], + setupFilesAfterEnv: ['/test/setup.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-react/package.json b/packages/cent-react/package.json new file mode 100644 index 0000000..e9c5275 --- /dev/null +++ b/packages/cent-react/package.json @@ -0,0 +1,55 @@ +{ + "name": "@thesis-co/cent-react", + "version": "0.0.1", + "description": "React bindings for @thesis-co/cent - display, input, and manage money values", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thesis/cent.git", + "directory": "packages/cent-react" + }, + "keywords": [ + "react", + "money", + "currency", + "finance", + "input", + "form", + "cents" + ], + "author": "Matt Luongo (@mhluongo)", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "pnpx @biomejs/biome check", + "lint:fix": "pnpx @biomejs/biome check --write", + "build": "tsc", + "test": "jest", + "prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint" + }, + "devDependencies": { + "@thesis-co/cent": "workspace:*", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^14.2.0", + "@testing-library/user-event": "^14.5.0", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ts-jest": "^29.1.2" + }, + "peerDependencies": { + "@thesis-co/cent": ">=0.0.5", + "react": ">=17.0.0" + } +} diff --git a/packages/cent-react/src/components/MoneyDiff.tsx b/packages/cent-react/src/components/MoneyDiff.tsx new file mode 100644 index 0000000..217bcda --- /dev/null +++ b/packages/cent-react/src/components/MoneyDiff.tsx @@ -0,0 +1,246 @@ +import { Money, MoneyClass, FixedPointNumber } from '@thesis-co/cent' +import { type ReactNode, useMemo } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +/** + * Render props for MoneyDiff custom rendering + */ +export interface MoneyDiffRenderProps { + /** The current value */ + current: MoneyInstance + /** The comparison value */ + compareTo: MoneyInstance + /** The difference (current - compareTo) */ + difference: MoneyInstance + /** Percentage change as string for precision (null if compareTo is zero) */ + percentageChange: string | null + /** Direction of change */ + direction: 'increase' | 'decrease' | 'unchanged' + /** Formatted strings */ + formatted: { + current: string + compareTo: string + difference: string + percentage: string + } +} + +export interface MoneyDiffProps { + /** Current/new value */ + value: MoneyInstance | string + + /** Value to compare against (previous/baseline) */ + compareTo: MoneyInstance | string + + /** Formatting options */ + formatOptions?: MoneyFormatOptions + + /** Show percentage change */ + showPercentage?: boolean + + /** Number of decimal places for percentage */ + percentageDecimals?: number + + /** CSS class name */ + className?: string + + /** Inline styles */ + style?: React.CSSProperties + + /** Element type to render (default: "span") */ + as?: React.ElementType + + /** Custom render function */ + children?: (props: MoneyDiffRenderProps) => ReactNode +} + +/** + * Coerce a value to Money + */ +function toMoney(value: MoneyInstance | string): MoneyInstance { + if (typeof value === 'string') { + return Money(value) as MoneyInstance + } + return value +} + +/** + * Calculate percentage change using FixedPointNumber for precision. + * Returns the percentage as a string to preserve precision. + */ +function calculatePercentageChange(current: MoneyInstance, compareTo: MoneyInstance, decimals: number): string | null { + if (compareTo.isZero()) { + return null + } + + try { + // (current - compareTo) / |compareTo| * 100 + const diff = current.subtract(compareTo) + const diffStr = diff.toString({ excludeCurrency: true }).replace(/,/g, '') + const compareStr = compareTo.absolute().toString({ excludeCurrency: true }).replace(/,/g, '') + + const diffFP = FixedPointNumber.fromDecimalString(diffStr) + const compareFP = FixedPointNumber.fromDecimalString(compareStr) + + if (compareFP.amount === 0n) return null + + // Align to same decimal scale + const maxDecimals = diffFP.decimals > compareFP.decimals ? diffFP.decimals : compareFP.decimals + const diffScaled = diffFP.amount * 10n ** (maxDecimals - diffFP.decimals) + const compareScaled = compareFP.amount * 10n ** (maxDecimals - compareFP.decimals) + + // For percentage with N decimal places, we need extra precision + // percentage = (diff / compare) * 100 + // We compute: (diff * 100 * 10^(decimals+1)) / compare, then round + const extraPrecision = BigInt(decimals + 1) + const multiplier = 100n * 10n ** extraPrecision + + const rawResult = (diffScaled * multiplier) / compareScaled + + // Create a FixedPointNumber with the result and use its toString for formatting + const resultFP = new FixedPointNumber(rawResult, extraPrecision) + + // Normalize to desired decimals (truncating extra precision) + const targetFP = new FixedPointNumber(0n, BigInt(decimals)) + const normalizedFP = resultFP.normalize(targetFP, true) // unsafe=true to allow truncation + + return normalizedFP.toString() + } catch { + return null + } +} + +/** + * Format percentage string with sign + */ +function formatPercentage(value: string | null): string { + if (value === null) return '' + + const isNegative = value.startsWith('-') + const isZero = value.replace(/[^1-9]/g, '') === '' + const absValue = isNegative ? value.slice(1) : value + + if (isZero) { + return `${absValue}%` + } else if (isNegative) { + return `-${absValue}%` + } else { + return `+${absValue}%` + } +} + +/** + * Display the difference between two Money values. + * + * @example + * // Basic usage + * + * // → "+$20.00" + * + * @example + * // With percentage + * + * // → "+$20.00 (+20.00%)" + * + * @example + * // Custom rendering + * + * {({ direction, formatted }) => ( + * + * {formatted.difference} + * + * )} + * + */ +export function MoneyDiff({ + value, + compareTo, + formatOptions, + showPercentage = false, + percentageDecimals = 2, + className, + style, + as: Component = 'span', + children, + ...rest +}: MoneyDiffProps & React.HTMLAttributes): ReactNode { + const renderProps = useMemo(() => { + const current = toMoney(value) + const compare = toMoney(compareTo) + const difference = current.subtract(compare) + + const percentageChange = calculatePercentageChange(current, compare, percentageDecimals) + + let direction: 'increase' | 'decrease' | 'unchanged' + if (difference.isPositive()) { + direction = 'increase' + } else if (difference.isNegative()) { + direction = 'decrease' + } else { + direction = 'unchanged' + } + + // Format the difference with sign + const absDiff = difference.absolute() + const diffFormatted = absDiff.toString(formatOptions) + const signedDiff = + direction === 'increase' + ? `+${diffFormatted}` + : direction === 'decrease' + ? `-${diffFormatted}` + : diffFormatted + + return { + current, + compareTo: compare, + difference, + percentageChange, + direction, + formatted: { + current: current.toString(formatOptions), + compareTo: compare.toString(formatOptions), + difference: signedDiff, + percentage: formatPercentage(percentageChange), + }, + } + }, [value, compareTo, formatOptions, percentageDecimals]) + + // Custom render + if (children) { + return ( + + {children(renderProps)} + + ) + } + + // Default render + const { formatted, direction } = renderProps + const displayText = showPercentage + ? `${formatted.difference} (${formatted.percentage})` + : formatted.difference + + return ( + + {displayText} + + ) +} diff --git a/packages/cent-react/src/components/MoneyDisplay.tsx b/packages/cent-react/src/components/MoneyDisplay.tsx new file mode 100644 index 0000000..13f960d --- /dev/null +++ b/packages/cent-react/src/components/MoneyDisplay.tsx @@ -0,0 +1,293 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { type ReactNode, useMemo } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +/** + * Parts of a formatted money value for custom rendering + */ +export interface MoneyParts { + /** The fully formatted string */ + formatted: string + /** Individual parts parsed from the formatted string (precision-safe) */ + parts: Array<{ type: string; value: string }> + /** Whether the value is negative */ + isNegative: boolean + /** Whether the value is zero */ + isZero: boolean + /** The original Money instance */ + money: MoneyInstance +} + +export interface MoneyDisplayProps { + /** The Money instance to display */ + value: MoneyInstance | string | null | undefined + + /** Display locale (default: "en-US") */ + locale?: string + + /** Use compact notation (e.g., $1M instead of $1,000,000) */ + compact?: boolean + + /** Maximum number of decimal places */ + maxDecimals?: number | bigint + + /** Minimum number of decimal places */ + minDecimals?: number | bigint + + /** Preferred fractional unit for crypto (e.g., "sat" for BTC) */ + preferredUnit?: string + + /** Use fractional unit symbol (e.g., "§10K" instead of "10K sats") */ + preferFractionalSymbol?: boolean + + /** Exclude currency symbol/code from output */ + excludeCurrency?: boolean + + /** Sign display mode */ + showSign?: 'always' | 'negative' | 'never' + + /** CSS class name */ + className?: string + + /** Inline styles */ + style?: React.CSSProperties + + /** Element type to render (default: "span") */ + as?: React.ElementType + + /** Content to show when value is null/undefined */ + placeholder?: ReactNode + + /** Custom render function for full control over rendering */ + children?: (parts: MoneyParts) => ReactNode +} + +/** + * Coerce a value to a Money instance + */ +function toMoney(value: MoneyInstance | string | null | undefined): MoneyInstance | null { + if (value == null) return null + if (typeof value === 'string') { + try { + return Money(value) as MoneyInstance + } catch { + return null + } + } + return value +} + +/** + * Parse a formatted money string into parts. + * This preserves full precision by parsing the string output from Money.toString() + * rather than converting to JavaScript Number. + */ +function parseFormattedParts(formatted: string): Array<{ type: string; value: string }> { + const parts: Array<{ type: string; value: string }> = [] + let remaining = formatted + let i = 0 + + while (i < remaining.length) { + const char = remaining[i] + + // Minus sign + if (char === '-') { + parts.push({ type: 'minusSign', value: '-' }) + i++ + continue + } + + // Plus sign + if (char === '+') { + parts.push({ type: 'plusSign', value: '+' }) + i++ + continue + } + + // Digits (collect consecutive digits as integer or fraction based on context) + if (/\d/.test(char)) { + let digits = '' + while (i < remaining.length && /\d/.test(remaining[i])) { + digits += remaining[i] + i++ + } + // Determine if this is integer or fraction based on whether we've seen a decimal + const hasDecimalBefore = parts.some(p => p.type === 'decimal') + parts.push({ type: hasDecimalBefore ? 'fraction' : 'integer', value: digits }) + continue + } + + // Decimal separator (period or comma depending on locale) + if (char === '.' || char === ',') { + // Check if this is a decimal or group separator + // If followed by exactly 3 digits and more content, likely group separator + // If followed by digits at end or non-digit, likely decimal + const afterChar = remaining.slice(i + 1) + const nextDigits = afterChar.match(/^(\d+)/) + + if (nextDigits && nextDigits[1].length === 3 && afterChar.length > 3 && /[\d,.]/.test(afterChar[3])) { + // Likely a group separator (thousands) + parts.push({ type: 'group', value: char }) + } else { + // Likely a decimal separator + parts.push({ type: 'decimal', value: char }) + } + i++ + continue + } + + // Whitespace + if (/\s/.test(char)) { + let ws = '' + while (i < remaining.length && /\s/.test(remaining[i])) { + ws += remaining[i] + i++ + } + parts.push({ type: 'literal', value: ws }) + continue + } + + // Currency symbols and other characters + // Collect consecutive non-digit, non-separator characters as currency + let other = '' + while (i < remaining.length && !/[\d.,\s+-]/.test(remaining[i])) { + other += remaining[i] + i++ + } + if (other) { + parts.push({ type: 'currency', value: other }) + } + } + + return parts +} + +/** + * Get formatted parts from a Money value. + * Uses Money.toString() for precision-safe formatting, then parses into parts. + */ +function getMoneyParts( + money: MoneyInstance, + options: MoneyFormatOptions, + showSign: 'always' | 'negative' | 'never' +): MoneyParts { + const formatted = money.toString(options) + const isNegative = money.isNegative() + const isZero = money.isZero() + + // Handle sign display + let finalFormatted = formatted + if (showSign === 'always' && !isNegative && !isZero) { + finalFormatted = `+${formatted}` + } else if (showSign === 'never' && isNegative) { + finalFormatted = formatted.replace(/^-/, '') + } + + // Parse the formatted string into parts (preserves full precision) + const parts = parseFormattedParts(finalFormatted) + + return { + formatted: finalFormatted, + parts, + isNegative, + isZero, + money, + } +} + +/** + * Display a Money value with formatting options. + * + * @example + * // Basic usage + * + * // → "$1,234.56" + * + * @example + * // Compact notation + * + * // → "$1.5M" + * + * @example + * // Custom parts rendering + * + * {({ parts }) => ( + * + * {parts.map((p, i) => ( + * {p.value} + * ))} + * + * )} + * + */ +export function MoneyDisplay({ + value, + locale, + compact, + maxDecimals, + minDecimals, + preferredUnit, + preferFractionalSymbol, + excludeCurrency, + showSign = 'negative', + className, + style, + as: Component = 'span', + placeholder, + children, + ...rest +}: MoneyDisplayProps & React.HTMLAttributes): ReactNode { + const money = useMemo(() => toMoney(value), [value]) + + const formatOptions: MoneyFormatOptions = useMemo( + () => ({ + locale, + compact, + maxDecimals, + minDecimals, + preferredUnit, + preferFractionalSymbol, + excludeCurrency, + }), + [locale, compact, maxDecimals, minDecimals, preferredUnit, preferFractionalSymbol, excludeCurrency] + ) + + const parts = useMemo(() => { + if (!money) return null + return getMoneyParts(money, formatOptions, showSign) + }, [money, formatOptions, showSign]) + + // Handle null/undefined value + if (!money || !parts) { + if (placeholder != null) { + return {placeholder} + } + return null + } + + // Custom render via children function + if (children) { + return {children(parts)} + } + + // Default render + return ( + + {parts.formatted} + + ) +} diff --git a/packages/cent-react/src/components/MoneyInput.tsx b/packages/cent-react/src/components/MoneyInput.tsx new file mode 100644 index 0000000..93a262e --- /dev/null +++ b/packages/cent-react/src/components/MoneyInput.tsx @@ -0,0 +1,286 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { + type InputHTMLAttributes, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** + * Change event for MoneyInput, compatible with react-hook-form and formik + */ +export interface MoneyInputChangeEvent { + target: { + name: string + value: MoneyInstance | null + } +} + +/** + * Blur event for MoneyInput + */ +export interface MoneyInputBlurEvent { + target: { + name: string + value: MoneyInstance | null + } +} + +export interface MoneyInputProps + extends Omit< + InputHTMLAttributes, + 'value' | 'onChange' | 'onBlur' | 'type' | 'defaultValue' | 'min' | 'max' + > { + /** Current Money value (controlled) */ + value?: MoneyInstance | null + + /** Field name - required for form integration */ + name: string + + /** Currency for parsing input (required) */ + currency: string + + /** + * onChange handler - designed for form library compatibility + * + * For react-hook-form: + * } /> + * + * For formik: + * + */ + onChange?: (event: MoneyInputChangeEvent) => void + + /** Alternative: direct value handler */ + onValueChange?: (value: MoneyInstance | null) => void + + /** Blur handler */ + onBlur?: (event: MoneyInputBlurEvent) => void + + /** Minimum allowed value */ + min?: MoneyInstance | string + + /** Maximum allowed value */ + max?: MoneyInstance | string + + /** Format the display on blur (default: true) */ + formatOnBlur?: boolean + + /** Display locale for formatting */ + locale?: string + + /** Allow negative values (default: true) */ + allowNegative?: boolean + + /** Select all text on focus (default: true) */ + selectOnFocus?: boolean +} + +/** + * Parse a string input to Money, with fallback to currency + */ +function parseInput(input: string, currency: string, allowNegative: boolean): MoneyInstance | null { + if (!input.trim()) { + return null + } + + try { + // Try parsing with currency prefix/suffix + const result = MoneyClass.parse(input) + if (result.ok) { + const money = result.value + if (!allowNegative && money.isNegative()) { + return money.absolute() + } + return money + } + + // Try parsing as a plain number with the specified currency + const cleaned = input.replace(/[,\s]/g, '') + const numMatch = cleaned.match(/^-?[\d.]+$/) + if (numMatch) { + // Pass as string to preserve precision + const money = Money(`${cleaned} ${currency}`) as MoneyInstance + if (!allowNegative && money.isNegative()) { + return money.absolute() + } + return money + } + + return null + } catch { + return null + } +} + +/** + * Format Money for display in the input + */ +function formatForDisplay(money: MoneyInstance | null, locale?: string): string { + if (!money) return '' + return money.toString({ + locale, + excludeCurrency: true, + }) +} + +/** + * A controlled money input component compatible with react-hook-form and formik. + * + * @example + * // Basic usage + * const [amount, setAmount] = useState(null); + * setAmount(e.target.value)} + * currency="USD" + * /> + * + * @example + * // With react-hook-form + * ( + * + * )} + * /> + */ +export const MoneyInput = forwardRef(function MoneyInput( + { + value, + name, + currency, + onChange, + onValueChange, + onBlur, + min, + max, + formatOnBlur = true, + locale, + allowNegative = true, + selectOnFocus = true, + placeholder, + disabled, + className, + style, + ...rest + }, + ref +) { + const inputRef = useRef(null) + useImperativeHandle(ref, () => inputRef.current!) + + // Track whether we're currently editing + const [isEditing, setIsEditing] = useState(false) + const [displayValue, setDisplayValue] = useState(() => formatForDisplay(value ?? null, locale)) + + // Sync display value with controlled value when not editing + // Only sync if value is explicitly controlled (not undefined) + useEffect(() => { + if (!isEditing && value !== undefined) { + setDisplayValue(formatForDisplay(value, locale)) + } + }, [value, isEditing, locale]) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.value + setDisplayValue(raw) + + const parsed = parseInput(raw, currency, allowNegative) + + // Validate min/max + let validatedValue = parsed + if (validatedValue) { + try { + if (min) { + const minMoney = typeof min === 'string' ? (Money(min) as MoneyInstance) : min + if (validatedValue.lessThan(minMoney)) { + // Allow the input but mark as invalid (validation is external) + } + } + if (max) { + const maxMoney = typeof max === 'string' ? (Money(max) as MoneyInstance) : max + if (validatedValue.greaterThan(maxMoney)) { + // Allow the input but mark as invalid (validation is external) + } + } + } catch { + // Ignore validation errors for mismatched currencies + } + } + + if (onChange) { + onChange({ target: { name, value: validatedValue } }) + } + if (onValueChange) { + onValueChange(validatedValue) + } + }, + [currency, allowNegative, min, max, name, onChange, onValueChange] + ) + + const handleFocus = useCallback( + (_e: React.FocusEvent) => { + setIsEditing(true) + + // Show raw value for editing (without formatting) + if (value) { + const rawValue = value.toString({ excludeCurrency: true }) + // Remove thousand separators for easier editing + setDisplayValue(rawValue.replace(/,/g, '')) + } + + if (selectOnFocus) { + // Use setTimeout to ensure the value is set before selecting + setTimeout(() => { + inputRef.current?.select() + }, 0) + } + }, + [value, selectOnFocus] + ) + + const handleBlur = useCallback( + (_e: React.FocusEvent) => { + setIsEditing(false) + + // Format on blur if enabled + if (formatOnBlur && value) { + setDisplayValue(formatForDisplay(value, locale)) + } + + if (onBlur) { + onBlur({ target: { name, value: value ?? null } }) + } + }, + [formatOnBlur, value, locale, name, onBlur] + ) + + return ( + + ) +}) diff --git a/packages/cent-react/src/components/index.ts b/packages/cent-react/src/components/index.ts new file mode 100644 index 0000000..2d44c42 --- /dev/null +++ b/packages/cent-react/src/components/index.ts @@ -0,0 +1,8 @@ +export { MoneyDisplay } from './MoneyDisplay' +export type { MoneyDisplayProps, MoneyParts } from './MoneyDisplay' + +export { MoneyInput } from './MoneyInput' +export type { MoneyInputProps, MoneyInputChangeEvent, MoneyInputBlurEvent } from './MoneyInput' + +export { MoneyDiff } from './MoneyDiff' +export type { MoneyDiffProps, MoneyDiffRenderProps } from './MoneyDiff' diff --git a/packages/cent-react/src/context/MoneyProvider.tsx b/packages/cent-react/src/context/MoneyProvider.tsx new file mode 100644 index 0000000..aff34d7 --- /dev/null +++ b/packages/cent-react/src/context/MoneyProvider.tsx @@ -0,0 +1,120 @@ +import { type CentConfig, type ExchangeRate, getConfig } from '@thesis-co/cent' +import { type ReactNode, createContext, useContext, useMemo } from 'react' + +/** + * Function to resolve exchange rates + */ +export type ExchangeRateResolver = ( + from: string, + to: string +) => Promise | ExchangeRate | null + +/** + * Context value for MoneyProvider + */ +export interface MoneyContextValue { + /** Default locale for formatting */ + locale: string + + /** Default currency for inputs */ + defaultCurrency: string | null + + /** Exchange rate resolver */ + exchangeRateResolver: ExchangeRateResolver | null + + /** Cent library config */ + config: CentConfig +} + +/** + * Props for MoneyProvider + */ +export interface MoneyProviderProps { + children: ReactNode + + /** Default locale for all money formatting (default: "en-US") */ + locale?: string + + /** Default currency for inputs */ + defaultCurrency?: string + + /** Exchange rate resolver for conversions */ + exchangeRateResolver?: ExchangeRateResolver + + /** Cent library config overrides */ + config?: Partial +} + +const MoneyContext = createContext(null) + +/** + * Provider for default Money configuration. + * + * @example + * // Set defaults for all descendant components + * + * + * + * + * @example + * // With exchange rate resolver + * { + * const rate = await fetchExchangeRate(from, to) + * return new ExchangeRate(from, to, rate) + * }} + * > + * + * + */ +export function MoneyProvider({ + children, + locale = 'en-US', + defaultCurrency, + exchangeRateResolver, + config: configOverrides, +}: MoneyProviderProps): ReactNode { + const parentContext = useContext(MoneyContext) + + const contextValue = useMemo(() => { + // Get base config from Cent library + const baseConfig = getConfig() + + // Merge with overrides + const mergedConfig: CentConfig = configOverrides + ? { ...baseConfig, ...configOverrides } + : baseConfig + + return { + locale: locale ?? parentContext?.locale ?? 'en-US', + defaultCurrency: defaultCurrency ?? parentContext?.defaultCurrency ?? null, + exchangeRateResolver: exchangeRateResolver ?? parentContext?.exchangeRateResolver ?? null, + config: mergedConfig, + } + }, [locale, defaultCurrency, exchangeRateResolver, configOverrides, parentContext]) + + return {children} +} + +/** + * Default context value when no provider is present + */ +function getDefaultContextValue(): MoneyContextValue { + return { + locale: 'en-US', + defaultCurrency: null, + exchangeRateResolver: null, + config: getConfig(), + } +} + +/** + * Hook to access the Money context. + * + * @example + * const { locale, defaultCurrency } = useMoneyConfig() + */ +export function useMoneyContext(): MoneyContextValue { + const context = useContext(MoneyContext) + return context ?? getDefaultContextValue() +} diff --git a/packages/cent-react/src/context/index.ts b/packages/cent-react/src/context/index.ts new file mode 100644 index 0000000..423b1c8 --- /dev/null +++ b/packages/cent-react/src/context/index.ts @@ -0,0 +1,6 @@ +export { MoneyProvider, useMoneyContext } from './MoneyProvider' +export type { + MoneyProviderProps, + MoneyContextValue, + ExchangeRateResolver, +} from './MoneyProvider' diff --git a/packages/cent-react/src/hooks/index.ts b/packages/cent-react/src/hooks/index.ts new file mode 100644 index 0000000..d7cabaf --- /dev/null +++ b/packages/cent-react/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useMoney } from './useMoney' +export type { UseMoneyOptions, UseMoneyReturn } from './useMoney' + +export { useExchangeRate } from './useExchangeRate' +export type { UseExchangeRateOptions, UseExchangeRateReturn } from './useExchangeRate' + +export { useMoneyConfig } from './useMoneyConfig' diff --git a/packages/cent-react/src/hooks/useExchangeRate.ts b/packages/cent-react/src/hooks/useExchangeRate.ts new file mode 100644 index 0000000..de226a2 --- /dev/null +++ b/packages/cent-react/src/hooks/useExchangeRate.ts @@ -0,0 +1,198 @@ +import { type ExchangeRate, MoneyClass } from '@thesis-co/cent' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMoneyContext } from '../context/MoneyProvider' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +export interface UseExchangeRateOptions { + /** Base currency code */ + from: string + + /** Quote currency code */ + to: string + + /** Auto-refresh interval in milliseconds (0 = disabled) */ + pollInterval?: number + + /** Time in ms after which rate is considered stale */ + staleThreshold?: number + + /** Whether to enable fetching (default: true) */ + enabled?: boolean +} + +export interface UseExchangeRateReturn { + /** Current exchange rate */ + rate: ExchangeRate | null + + /** Whether a fetch is in progress */ + isLoading: boolean + + /** Whether the rate is stale */ + isStale: boolean + + /** Error from the last fetch attempt */ + error: Error | null + + /** Time since last successful fetch in ms */ + age: number + + /** Convert a Money value using the current rate */ + convert: (money: MoneyInstance) => MoneyInstance | null + + /** Manually trigger a refetch */ + refetch: () => Promise +} + +/** + * Hook for fetching and managing exchange rates. + * + * Uses the exchangeRateResolver from MoneyProvider context. + * + * @example + * // Basic usage + * const { rate, convert, isLoading } = useExchangeRate({ + * from: 'USD', + * to: 'EUR' + * }) + * + * const eurAmount = convert(usdAmount) + * + * @example + * // With polling + * const { rate, isStale, refetch } = useExchangeRate({ + * from: 'BTC', + * to: 'USD', + * pollInterval: 30000, // 30 seconds + * staleThreshold: 60000 // 1 minute + * }) + */ +export function useExchangeRate(options: UseExchangeRateOptions): UseExchangeRateReturn { + const { from, to, pollInterval = 0, staleThreshold = 300000, enabled = true } = options + + const { exchangeRateResolver } = useMoneyContext() + + const [rate, setRate] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [lastFetchTime, setLastFetchTime] = useState(null) + const [age, setAge] = useState(0) + + const abortControllerRef = useRef(null) + + // Fetch the exchange rate + const fetchRate = useCallback(async () => { + if (!exchangeRateResolver) { + setError(new Error('No exchange rate resolver configured in MoneyProvider')) + return + } + + // Cancel any pending request + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + abortControllerRef.current = new AbortController() + + setIsLoading(true) + setError(null) + + try { + const result = await exchangeRateResolver(from, to) + setRate(result) + setLastFetchTime(Date.now()) + setError(null) + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { + // Ignore abort errors + return + } + setError(e instanceof Error ? e : new Error('Failed to fetch exchange rate')) + } finally { + setIsLoading(false) + } + }, [exchangeRateResolver, from, to]) + + // Initial fetch + useEffect(() => { + if (enabled) { + if (exchangeRateResolver) { + fetchRate() + } else { + // Set error when no resolver is configured + setError(new Error('No exchange rate resolver configured in MoneyProvider')) + } + } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, [enabled, exchangeRateResolver, from, to, fetchRate]) + + // Polling + useEffect(() => { + if (!enabled || pollInterval <= 0) { + return + } + + const intervalId = setInterval(fetchRate, pollInterval) + return () => clearInterval(intervalId) + }, [enabled, pollInterval, fetchRate]) + + // Update age + useEffect(() => { + if (!lastFetchTime) { + setAge(0) + return + } + + const updateAge = () => { + setAge(Date.now() - lastFetchTime) + } + + updateAge() + const intervalId = setInterval(updateAge, 1000) + return () => clearInterval(intervalId) + }, [lastFetchTime]) + + // Calculate staleness + const isStale = lastFetchTime ? age > staleThreshold : false + + // Convert function + const convert = useCallback( + (money: MoneyInstance): MoneyInstance | null => { + if (!rate) return null + + try { + // Use the ExchangeRate's convert method if available + if ('convert' in rate && typeof rate.convert === 'function') { + return rate.convert(money) as MoneyInstance + } + + // Fallback: manual conversion + // This assumes the rate is a simple numeric multiplier + if ('rate' in rate) { + const rateValue = rate.rate + return money.multiply(rateValue.toString()) as MoneyInstance + } + + return null + } catch { + return null + } + }, + [rate] + ) + + return { + rate, + isLoading, + isStale, + error, + age, + convert, + refetch: fetchRate, + } +} diff --git a/packages/cent-react/src/hooks/useMoney.ts b/packages/cent-react/src/hooks/useMoney.ts new file mode 100644 index 0000000..f5d4164 --- /dev/null +++ b/packages/cent-react/src/hooks/useMoney.ts @@ -0,0 +1,266 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { useCallback, useMemo, useState } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +export interface UseMoneyOptions { + /** Initial Money value */ + initialValue?: MoneyInstance | string | null + + /** Currency for parsing string inputs */ + currency?: string + + /** Minimum allowed value for validation */ + min?: MoneyInstance | string + + /** Maximum allowed value for validation */ + max?: MoneyInstance | string +} + +export interface UseMoneyReturn { + /** Current Money value */ + money: MoneyInstance | null + + /** Set Money value from various inputs */ + setMoney: (value: MoneyInstance | string | number | null) => void + + /** Format the current value */ + format: (options?: MoneyFormatOptions) => string + + /** Whether the current value is valid */ + isValid: boolean + + /** Current validation error, if any */ + error: Error | null + + /** Reset to initial value */ + reset: () => void + + /** Clear the value */ + clear: () => void + + /** + * Props to spread on a native input element + * @example + * const { inputProps } = useMoney({ currency: 'USD' }) + * + */ + inputProps: { + value: string + onChange: (e: React.ChangeEvent) => void + onBlur: () => void + } +} + +/** + * Parse a value to Money + */ +function parseMoney(value: MoneyInstance | string | number | null, currency?: string): MoneyInstance | null { + if (value == null) return null + + if (typeof value === 'object' && 'currency' in value) { + // Already a Money instance + return value as MoneyInstance + } + + if (typeof value === 'number') { + if (!currency) { + throw new Error('Currency is required when setting from a number') + } + // Convert number to string to preserve precision + return Money(`${value} ${currency}`) as MoneyInstance + } + + if (typeof value === 'string') { + if (!value.trim()) return null + + // Try parsing with embedded currency + const result = MoneyClass.parse(value) + if (result.ok) { + return result.value + } + + // Try parsing as number with provided currency + if (currency) { + const cleaned = value.replace(/[,\s]/g, '') + const numMatch = cleaned.match(/^-?[\d.]+$/) + if (numMatch) { + // Pass as string to preserve precision + return Money(`${cleaned} ${currency}`) as MoneyInstance + } + } + + return null + } + + return null +} + +/** + * Validate Money against constraints + */ +function validateMoney( + money: MoneyInstance | null, + min?: MoneyInstance | string, + max?: MoneyInstance | string +): Error | null { + if (!money) return null + + try { + if (min) { + const minMoney = typeof min === 'string' ? (Money(min) as MoneyInstance) : min + if (money.lessThan(minMoney)) { + return new Error(`Value must be at least ${minMoney.toString()}`) + } + } + + if (max) { + const maxMoney = typeof max === 'string' ? (Money(max) as MoneyInstance) : max + if (money.greaterThan(maxMoney)) { + return new Error(`Value must be at most ${maxMoney.toString()}`) + } + } + } catch (e) { + // Currency mismatch or other error + return e instanceof Error ? e : new Error('Validation error') + } + + return null +} + +/** + * Hook for managing Money state with validation. + * + * @example + * // Basic usage + * const { money, setMoney, format } = useMoney({ currency: 'USD' }) + * + * @example + * // With validation + * const { money, isValid, error } = useMoney({ + * currency: 'USD', + * min: '$0.01', + * max: '$1000' + * }) + * + * @example + * // With native input binding + * const { inputProps } = useMoney({ currency: 'USD' }) + * + */ +export function useMoney(options: UseMoneyOptions = {}): UseMoneyReturn { + const { initialValue, currency, min, max } = options + + const [money, setMoneyState] = useState(() => { + if (initialValue == null) return null + try { + return parseMoney(initialValue, currency) + } catch { + return null + } + }) + + const [displayValue, setDisplayValue] = useState(() => { + return money?.toString({ excludeCurrency: true }) ?? '' + }) + + // Validation + const error = useMemo(() => validateMoney(money, min, max), [money, min, max]) + const isValid = error === null + + // Set money from various inputs + const setMoney = useCallback( + (value: MoneyInstance | string | number | null) => { + try { + const parsed = parseMoney(value, currency) + setMoneyState(parsed) + setDisplayValue(parsed?.toString({ excludeCurrency: true }) ?? '') + } catch { + setMoneyState(null) + setDisplayValue('') + } + }, + [currency] + ) + + // Format the current value + const format = useCallback( + (formatOptions?: MoneyFormatOptions) => { + if (!money) return '' + return money.toString(formatOptions) + }, + [money] + ) + + // Reset to initial value + const reset = useCallback(() => { + try { + const initial = initialValue != null ? parseMoney(initialValue, currency) : null + setMoneyState(initial) + setDisplayValue(initial?.toString({ excludeCurrency: true }) ?? '') + } catch { + setMoneyState(null) + setDisplayValue('') + } + }, [initialValue, currency]) + + // Clear the value + const clear = useCallback(() => { + setMoneyState(null) + setDisplayValue('') + }, []) + + // Input props for native input binding + const inputProps = useMemo( + () => ({ + value: displayValue, + onChange: (e: React.ChangeEvent) => { + const raw = e.target.value + setDisplayValue(raw) + + if (!raw.trim()) { + setMoneyState(null) + return + } + + try { + const parsed = parseMoney(raw, currency) + setMoneyState(parsed) + } catch { + // Keep display value but don't update money + } + }, + onBlur: () => { + // Format on blur + if (money) { + setDisplayValue(money.toString({ excludeCurrency: true })) + } + }, + }), + [displayValue, currency, money] + ) + + return { + money, + setMoney, + format, + isValid, + error, + reset, + clear, + inputProps, + } +} diff --git a/packages/cent-react/src/hooks/useMoneyConfig.ts b/packages/cent-react/src/hooks/useMoneyConfig.ts new file mode 100644 index 0000000..864bd3e --- /dev/null +++ b/packages/cent-react/src/hooks/useMoneyConfig.ts @@ -0,0 +1,2 @@ +// Re-export from context for convenience +export { useMoneyContext as useMoneyConfig } from '../context/MoneyProvider' diff --git a/packages/cent-react/src/index.ts b/packages/cent-react/src/index.ts new file mode 100644 index 0000000..ec012f2 --- /dev/null +++ b/packages/cent-react/src/index.ts @@ -0,0 +1,22 @@ +// Components +export { MoneyDisplay } from './components/MoneyDisplay' +export type { MoneyDisplayProps, MoneyParts } from './components/MoneyDisplay' + +export { MoneyInput } from './components/MoneyInput' +export type { MoneyInputProps, MoneyInputChangeEvent } from './components/MoneyInput' + +export { MoneyDiff } from './components/MoneyDiff' +export type { MoneyDiffProps, MoneyDiffRenderProps } from './components/MoneyDiff' + +// Hooks +export { useMoney } from './hooks/useMoney' +export type { UseMoneyOptions, UseMoneyReturn } from './hooks/useMoney' + +export { useExchangeRate } from './hooks/useExchangeRate' +export type { UseExchangeRateOptions, UseExchangeRateReturn } from './hooks/useExchangeRate' + +export { useMoneyConfig } from './hooks/useMoneyConfig' + +// Context +export { MoneyProvider } from './context/MoneyProvider' +export type { MoneyProviderProps, MoneyContextValue } from './context/MoneyProvider' diff --git a/packages/cent-react/test/components/MoneyDiff.test.tsx b/packages/cent-react/test/components/MoneyDiff.test.tsx new file mode 100644 index 0000000..dce612b --- /dev/null +++ b/packages/cent-react/test/components/MoneyDiff.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { MoneyDiff } from '../../src/components/MoneyDiff' + +describe('MoneyDiff', () => { + describe('basic rendering', () => { + it('renders positive difference with plus sign', () => { + render() + expect(screen.getByText('+$20.00')).toBeInTheDocument() + }) + + it('renders negative difference with minus sign', () => { + render() + expect(screen.getByText('-$20.00')).toBeInTheDocument() + }) + + it('renders zero difference without sign', () => { + render() + expect(screen.getByText('$0.00')).toBeInTheDocument() + }) + + it('accepts string values', () => { + render() + expect(screen.getByText('+$50.00')).toBeInTheDocument() + }) + }) + + describe('percentage display', () => { + it('shows percentage when showPercentage is true', () => { + render() + expect(screen.getByText('+$20.00 (+20.00%)')).toBeInTheDocument() + }) + + it('shows negative percentage for decrease', () => { + render() + expect(screen.getByText('-$20.00 (-20.00%)')).toBeInTheDocument() + }) + + it('respects percentageDecimals option', () => { + render( + + ) + expect(screen.getByText(/33\.3%/)).toBeInTheDocument() + }) + }) + + describe('data-direction attribute', () => { + it('sets data-direction to increase for positive diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'increase') + }) + + it('sets data-direction to decrease for negative diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'decrease') + }) + + it('sets data-direction to unchanged for zero diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'unchanged') + }) + }) + + describe('custom rendering', () => { + it('passes render props to children function', () => { + render( + + {({ direction, formatted, percentageChange }) => ( + + {direction}: {formatted.difference} ({percentageChange}%) + + )} + + ) + + expect(screen.getByTestId('custom')).toHaveTextContent('increase: +$50.00 (50.00%)') + }) + + it('provides Money instances in render props', () => { + let receivedCurrent: Money | null = null + let receivedDiff: Money | null = null + + render( + + {({ current, difference }) => { + receivedCurrent = current + receivedDiff = difference + return test + }} + + ) + + expect(receivedCurrent?.toString()).toBe('$120.00') + expect(receivedDiff?.toString()).toBe('$20.00') + }) + }) + + describe('styling', () => { + it('applies className', () => { + render( + + ) + expect(screen.getByTestId('diff')).toHaveClass('my-diff') + }) + + it('renders as different element type', () => { + render( + + ) + expect(screen.getByTestId('diff').tagName).toBe('DIV') + }) + }) +}) diff --git a/packages/cent-react/test/components/MoneyDisplay.test.tsx b/packages/cent-react/test/components/MoneyDisplay.test.tsx new file mode 100644 index 0000000..cbacc31 --- /dev/null +++ b/packages/cent-react/test/components/MoneyDisplay.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { MoneyDisplay } from '../../src/components/MoneyDisplay' + +describe('MoneyDisplay', () => { + describe('basic rendering', () => { + it('renders formatted money value', () => { + render() + expect(screen.getByText('$100.50')).toBeInTheDocument() + }) + + it('renders with string value', () => { + render() + expect(screen.getByText('$200.00')).toBeInTheDocument() + }) + + it('renders nothing for null value without placeholder', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders placeholder for null value', () => { + render() + expect(screen.getByText('—')).toBeInTheDocument() + }) + + it('renders placeholder for undefined value', () => { + render() + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + }) + + describe('formatting options', () => { + it('applies compact notation', () => { + render() + // Compact notation varies by browser, just check it renders + expect(screen.getByText(/\$1\.5M|\$1,500K/)).toBeInTheDocument() + }) + + it('applies maxDecimals', () => { + render() + expect(screen.getByText('$101.00')).toBeInTheDocument() + }) + + it('excludes currency when requested', () => { + render() + expect(screen.getByText('100.00')).toBeInTheDocument() + }) + }) + + describe('showSign prop', () => { + it('shows negative sign by default for negative values', () => { + render() + expect(screen.getByText(/-\$50\.00|-50\.00/)).toBeInTheDocument() + }) + + it('shows positive sign when showSign is always', () => { + render() + expect(screen.getByText(/\+\$50\.00/)).toBeInTheDocument() + }) + + it('hides sign when showSign is never', () => { + render() + expect(screen.getByText('$50.00')).toBeInTheDocument() + }) + }) + + describe('custom rendering', () => { + it('passes parts to children function', () => { + render( + + {({ formatted, isNegative, isZero }) => ( + + {formatted} - neg:{String(isNegative)} - zero:{String(isZero)} + + )} + + ) + + const element = screen.getByTestId('custom') + expect(element).toHaveTextContent('$99.99') + expect(element).toHaveTextContent('neg:false') + expect(element).toHaveTextContent('zero:false') + }) + + it('provides money instance in parts', () => { + let receivedMoney: Money | null = null + + render( + + {({ money }) => { + receivedMoney = money + return test + }} + + ) + + expect(receivedMoney).not.toBeNull() + expect(receivedMoney!.toString()).toBe('$100.00') + }) + }) + + describe('styling', () => { + it('applies className', () => { + render() + expect(screen.getByText('$100.00')).toHaveClass('my-class') + }) + + it('applies style', () => { + render() + expect(screen.getByText('$100.00')).toHaveStyle({ color: 'red' }) + }) + + it('renders as different element type', () => { + render() + const element = screen.getByTestId('money') + expect(element.tagName).toBe('DIV') + }) + }) +}) diff --git a/packages/cent-react/test/components/MoneyInput.test.tsx b/packages/cent-react/test/components/MoneyInput.test.tsx new file mode 100644 index 0000000..3d8e5e6 --- /dev/null +++ b/packages/cent-react/test/components/MoneyInput.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Money } from '@thesis-co/cent' +import { MoneyInput } from '../../src/components/MoneyInput' + +describe('MoneyInput', () => { + describe('basic rendering', () => { + it('renders an input element', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('displays controlled value', () => { + render() + expect(screen.getByRole('textbox')).toHaveValue('100.00') + }) + + it('displays placeholder', () => { + render() + expect(screen.getByPlaceholderText('Enter amount')).toBeInTheDocument() + }) + }) + + describe('user input', () => { + it('calls onChange with parsed Money value', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '50.00') + + expect(onChange).toHaveBeenCalled() + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.name).toBe('amount') + expect(lastCall.target.value).not.toBeNull() + }) + + it('calls onValueChange with Money value', async () => { + const user = userEvent.setup() + const onValueChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '75') + + expect(onValueChange).toHaveBeenCalled() + }) + + it('handles clearing input', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.clear(input) + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value).toBeNull() + }) + }) + + describe('formatting', () => { + it('formats value on blur when formatOnBlur is true with controlled value', () => { + const value = Money('$1234.50') + render( + + ) + + const input = screen.getByRole('textbox') + // Controlled value should display formatted (without currency since excludeCurrency) + expect(input).toHaveValue('1,234.50') + }) + + it('displays unformatted value when controlled value is set', () => { + const value = Money('$100') + render( + + ) + + const input = screen.getByRole('textbox') + expect(input).toHaveValue('100.00') + }) + }) + + describe('validation', () => { + it('allows negative values by default', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '-50') + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value?.isNegative()).toBe(true) + }) + + it('converts negative to positive when allowNegative is false', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '-50') + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value?.isNegative()).toBe(false) + }) + }) + + describe('props passthrough', () => { + it('passes disabled prop', () => { + render() + expect(screen.getByRole('textbox')).toBeDisabled() + }) + + it('passes className', () => { + render() + expect(screen.getByRole('textbox')).toHaveClass('my-input') + }) + + it('sets inputMode to decimal', () => { + render() + expect(screen.getByRole('textbox')).toHaveAttribute('inputMode', 'decimal') + }) + }) + + describe('ref forwarding', () => { + it('forwards ref to input element', () => { + const ref = { current: null as HTMLInputElement | null } + render() + expect(ref.current).toBeInstanceOf(HTMLInputElement) + }) + }) +}) diff --git a/packages/cent-react/test/context/MoneyProvider.test.tsx b/packages/cent-react/test/context/MoneyProvider.test.tsx new file mode 100644 index 0000000..f1e3511 --- /dev/null +++ b/packages/cent-react/test/context/MoneyProvider.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react' +import { MoneyProvider, useMoneyContext } from '../../src/context/MoneyProvider' + +// Test component that displays context values +function ContextDisplay() { + const { locale, defaultCurrency } = useMoneyContext() + return ( +
+ {locale} + {defaultCurrency ?? 'none'} +
+ ) +} + +describe('MoneyProvider', () => { + describe('default values', () => { + it('provides default locale without provider', () => { + render() + expect(screen.getByTestId('locale')).toHaveTextContent('en-US') + }) + + it('provides null defaultCurrency without provider', () => { + render() + expect(screen.getByTestId('currency')).toHaveTextContent('none') + }) + }) + + describe('with provider', () => { + it('provides custom locale', () => { + render( + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + }) + + it('provides custom defaultCurrency', () => { + render( + + + + ) + expect(screen.getByTestId('currency')).toHaveTextContent('EUR') + }) + + it('provides multiple values', () => { + render( + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('fr-FR') + expect(screen.getByTestId('currency')).toHaveTextContent('CHF') + }) + }) + + describe('nesting', () => { + it('nested provider overrides parent values', () => { + render( + + + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + expect(screen.getByTestId('currency')).toHaveTextContent('EUR') + }) + + it('nested provider inherits unspecified values from parent', () => { + render( + + + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + expect(screen.getByTestId('currency')).toHaveTextContent('GBP') + }) + }) + + describe('exchangeRateResolver', () => { + it('provides resolver to context', () => { + const mockResolver = jest.fn() + let receivedResolver: typeof mockResolver | null = null + + function ResolverCapture() { + const { exchangeRateResolver } = useMoneyContext() + receivedResolver = exchangeRateResolver + return null + } + + render( + + + + ) + + expect(receivedResolver).toBe(mockResolver) + }) + + it('inherits resolver from parent when not specified', () => { + const mockResolver = jest.fn() + let receivedResolver: typeof mockResolver | null = null + + function ResolverCapture() { + const { exchangeRateResolver } = useMoneyContext() + receivedResolver = exchangeRateResolver + return null + } + + render( + + + + + + ) + + expect(receivedResolver).toBe(mockResolver) + }) + }) +}) diff --git a/packages/cent-react/test/hooks/useExchangeRate.test.tsx b/packages/cent-react/test/hooks/useExchangeRate.test.tsx new file mode 100644 index 0000000..a8bddd7 --- /dev/null +++ b/packages/cent-react/test/hooks/useExchangeRate.test.tsx @@ -0,0 +1,113 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { ExchangeRate, Money } from '@thesis-co/cent' +import type { ReactNode } from 'react' +import { MoneyProvider } from '../../src/context/MoneyProvider' +import { useExchangeRate } from '../../src/hooks/useExchangeRate' + +describe('useExchangeRate', () => { + const mockResolver = jest.fn() + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('initial state', () => { + it('starts with null rate', () => { + mockResolver.mockResolvedValue(null) + + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(result.current.rate).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('returns error when no resolver configured', () => { + const { result } = renderHook(() => useExchangeRate({ from: 'USD', to: 'EUR' })) + + // Should have error since no provider + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('No exchange rate resolver') + }) + }) + + describe('fetching', () => { + it('does not fetch when enabled is false', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(mockResolver).not.toHaveBeenCalled() + expect(result.current.rate).toBeNull() + }) + + it('calls resolver with correct currencies', async () => { + mockResolver.mockResolvedValue(null) + + renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR' }), + { wrapper } + ) + + await waitFor(() => { + expect(mockResolver).toHaveBeenCalledWith('USD', 'EUR') + }) + }) + }) + + describe('staleness', () => { + it('isStale is false initially when no rate fetched', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false, staleThreshold: 1000 }), + { wrapper } + ) + + expect(result.current.isStale).toBe(false) + }) + }) + + describe('convert', () => { + it('returns null when no rate available', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + const usd = Money('$100') + expect(result.current.convert(usd)).toBeNull() + }) + }) + + describe('options', () => { + it('respects enabled option', () => { + const { result, rerender } = renderHook( + ({ enabled }) => useExchangeRate({ from: 'USD', to: 'EUR', enabled }), + { wrapper, initialProps: { enabled: false } } + ) + + expect(mockResolver).not.toHaveBeenCalled() + + // Enable and check if it fetches + rerender({ enabled: true }) + + // Just verify the hook doesn't crash + expect(result.current.rate).toBeNull() + }) + + it('provides refetch function', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(typeof result.current.refetch).toBe('function') + }) + }) +}) diff --git a/packages/cent-react/test/hooks/useMoney.test.ts b/packages/cent-react/test/hooks/useMoney.test.ts new file mode 100644 index 0000000..1f77896 --- /dev/null +++ b/packages/cent-react/test/hooks/useMoney.test.ts @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { useMoney } from '../../src/hooks/useMoney' + +describe('useMoney', () => { + describe('initialization', () => { + it('initializes with null when no initialValue provided', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + expect(result.current.money).toBeNull() + }) + + it('initializes with Money from string initialValue', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100.00', currency: 'USD' }) + ) + expect(result.current.money).not.toBeNull() + expect(result.current.money?.toString()).toBe('$100.00') + }) + + it('initializes with Money instance', () => { + const initial = Money('$50.00') + const { result } = renderHook(() => useMoney({ initialValue: initial })) + expect(result.current.money?.toString()).toBe('$50.00') + }) + }) + + describe('setMoney', () => { + it('sets money from string', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney('$200.00') + }) + + expect(result.current.money?.toString()).toBe('$200.00') + }) + + it('sets money from string with currency', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney('150') + }) + + expect(result.current.money?.toString()).toBe('$150.00') + }) + + it('sets money to null', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.setMoney(null) + }) + + expect(result.current.money).toBeNull() + }) + + it('sets money from Money instance', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney(Money('€50.00')) + }) + + expect(result.current.money?.toString()).toBe('€50.00') + }) + }) + + describe('validation', () => { + it('returns isValid true when no constraints violated', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$50', currency: 'USD', min: '$10', max: '$100' }) + ) + + expect(result.current.isValid).toBe(true) + expect(result.current.error).toBeNull() + }) + + it('returns error when value below min', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$5', currency: 'USD', min: '$10' }) + ) + + expect(result.current.isValid).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('at least') + }) + + it('returns error when value above max', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$150', currency: 'USD', max: '$100' }) + ) + + expect(result.current.isValid).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('at most') + }) + + it('isValid is true for null value', () => { + const { result } = renderHook(() => + useMoney({ currency: 'USD', min: '$10' }) + ) + + expect(result.current.money).toBeNull() + expect(result.current.isValid).toBe(true) + }) + }) + + describe('format', () => { + it('formats money with default options', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1234.56', currency: 'USD' }) + ) + + expect(result.current.format()).toBe('$1,234.56') + }) + + it('formats with custom options', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1500000', currency: 'USD' }) + ) + + expect(result.current.format({ compact: true })).toMatch(/\$1\.5M|\$1,500K/) + }) + + it('returns empty string for null money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + expect(result.current.format()).toBe('') + }) + }) + + describe('reset and clear', () => { + it('reset returns to initial value', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.setMoney('$500') + }) + expect(result.current.money?.toString()).toBe('$500.00') + + act(() => { + result.current.reset() + }) + expect(result.current.money?.toString()).toBe('$100.00') + }) + + it('clear sets money to null', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.clear() + }) + expect(result.current.money).toBeNull() + }) + }) + + describe('inputProps', () => { + it('provides value as string', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + expect(result.current.inputProps.value).toBe('100.00') + }) + + it('provides empty string for null money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + expect(result.current.inputProps.value).toBe('') + }) + + it('onChange updates money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.inputProps.onChange({ + target: { value: '75.50' }, + } as React.ChangeEvent) + }) + + expect(result.current.money?.toString()).toBe('$75.50') + }) + + it('onBlur formats the display value', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1234.5', currency: 'USD' }) + ) + + act(() => { + result.current.inputProps.onBlur() + }) + + // After blur, should be formatted with proper decimals + expect(result.current.inputProps.value).toBe('1,234.50') + }) + }) +}) diff --git a/packages/cent-react/test/setup.ts b/packages/cent-react/test/setup.ts new file mode 100644 index 0000000..84b6513 --- /dev/null +++ b/packages/cent-react/test/setup.ts @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom' +import { configure } from '@thesis-co/cent' + +// Configure Cent with strict settings to catch any precision issues in tests +configure({ + numberInputMode: 'never', // Disallow Number inputs entirely + defaultRoundingMode: 'none', // No implicit rounding + strictPrecision: true, // Throw on any precision loss +}) diff --git a/packages/cent-react/tsconfig.json b/packages/cent-react/tsconfig.json new file mode 100644 index 0000000..499309a --- /dev/null +++ b/packages/cent-react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cent-supabase/README.md b/packages/cent-supabase/README.md new file mode 100644 index 0000000..25cdb71 --- /dev/null +++ b/packages/cent-supabase/README.md @@ -0,0 +1,168 @@ +# @thesis-co/cent-supabase + +Integration for `@thesis-co/cent` for easy storage and querying in +Supabase. + +## The problem + +The Supabase client returns `DECIMAL` columns as JSON numbers, losing +precision: + +```typescript +// Database stores: 19.99 +const { data } = await supabase.from('products').select('price').single() +console.log(data.price) // 19.990000000000002 +``` + +This package wraps the Supabase client to cast money columns to text on the wire, then converts them to `Money` objects in your app. + +## Installation + +```bash +npm install @thesis-co/cent-supabase @thesis-co/cent @supabase/supabase-js +``` + +## Quick start + +```typescript +import { createCentSupabaseClient } from '@thesis-co/cent-supabase' +import { Money } from '@thesis-co/cent' + +const supabase = createCentSupabaseClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, + { + tables: { + products: { + money: { + // statically defined currencies (every price is in USD) + price: { currencyCode: 'USD' }, + cost: { currencyCode: 'USD' } + } + }, + orders: { + money: { + // for dynamic currencies (each row has a + // total and total_currency) + total: { currencyColumn: 'total_currency' }, + tax: { currencyColumn: 'tax_currency' } + } + } + } + } +) + +// SELECT — returns Money objects +const { data } = await supabase.from('products').select('*') +console.log(data[0].price.toString()) // "$29.99" + +// INSERT — accepts Money objects +await supabase.from('orders').insert({ + total: Money('€150.00'), + tax: Money('€15.00') + // 'currency' column auto-populated as 'EUR' +}) + +// Aggregates work too +const { data: stats } = await supabase.from('orders').select('sum(total)').single() +console.log(stats.sum.toString()) // "$1,234.56" +``` + +## Configuration + +### Static currency + +When all rows use the same currency: + +```typescript +products: { + money: { + price: { currencyCode: 'USD' } + } +} +``` + +### Dynamic currency + +When currency varies per row (stored in another column): + +```typescript +orders: { + money: { + total: { currencyColumn: 'currency' } + } +} +``` + +On insert, the currency column is auto-populated from the Money object. + +### Minor units + +When storing cents, satoshis, or wei as integers: + +```typescript +transactions: { + money: { + amount_sats: { currencyCode: 'BTC', minorUnits: true } + } +} +// Database: 150000000 → Money("1.5 BTC") +``` + +## Realtime + +Subscriptions automatically transform payloads: + +```typescript +supabase + .channel('orders') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'orders' }, (payload) => { + console.log(payload.new.total.toString()) // Money object + }) + .subscribe() +``` + +## Helper functions + +For RPC results or manual transformations: + +```typescript +import { parseMoneyResult, serializeMoney, moneySelect } from '@thesis-co/cent-supabase' + +// Transform RPC results +const { data } = await supabase.rpc('calculate_total', { order_id: '...' }) +const result = parseMoneyResult(data, { total: { currencyCode: 'USD' } }) + +// Serialize Money for custom mutations +const serialized = serializeMoney({ price: Money('$99.99') }, { price: { currencyCode: 'USD' } }) +// { price: '99.99' } + +// Build select string with casts +moneySelect('id, name, price', ['price']) // "id, name, price::text" +``` + +## Limitations + +- **Nested relations**: Money columns in nested selects (e.g., `orders(items(price))`) aren't auto-transformed. Use `parseMoneyResult` on nested data. +- **Computed expressions**: Use explicit `::text` cast: `.select('(price * qty)::text as subtotal')` +- **RPC functions**: Transform results with `parseMoneyResult` + +## Database Schema + +Use `DECIMAL`/`NUMERIC`, not PostgreSQL's `MONEY` type: + +```sql +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + total DECIMAL(19,4) NOT NULL, + currency TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +) +``` + +| Use Case | PostgreSQL Type | +|----------|-----------------| +| USD, EUR | `DECIMAL(19,4)` | +| BTC (8 decimals) | `DECIMAL(28,8)` | +| ETH (18 decimals) | `DECIMAL(38,18)` | +| Minor units | `BIGINT` | diff --git a/packages/cent-supabase/jest.config.js b/packages/cent-supabase/jest.config.js new file mode 100644 index 0000000..5a924bc --- /dev/null +++ b/packages/cent-supabase/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + setupFilesAfterEnv: ['/test/setup.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-supabase/package.json b/packages/cent-supabase/package.json new file mode 100644 index 0000000..0d747f4 --- /dev/null +++ b/packages/cent-supabase/package.json @@ -0,0 +1,50 @@ +{ + "name": "@thesis-co/cent-supabase", + "version": "0.0.1", + "description": "Supabase integration for @thesis-co/cent - precision-safe money handling for Supabase/PostgREST", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thesis/cent.git", + "directory": "packages/cent-supabase" + }, + "keywords": [ + "supabase", + "postgrest", + "money", + "currency", + "finance", + "precision", + "decimal", + "bigint" + ], + "author": "Matt Luongo (@mhluongo)", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "pnpx @biomejs/biome check", + "lint:fix": "pnpx @biomejs/biome check --write", + "build": "tsc", + "test": "jest", + "prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint" + }, + "devDependencies": { + "@thesis-co/cent": "workspace:*", + "@supabase/supabase-js": "^2.49.1", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.5.4" + }, + "peerDependencies": { + "@thesis-co/cent": ">=0.0.5", + "@supabase/supabase-js": ">=2.0.0" + } +} diff --git a/packages/cent-supabase/src/client.ts b/packages/cent-supabase/src/client.ts new file mode 100644 index 0000000..f1f5807 --- /dev/null +++ b/packages/cent-supabase/src/client.ts @@ -0,0 +1,81 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import type { CentSupabaseOptions, NormalizedConfig } from "./types" +import { normalizeConfig } from "./types" +import { createClientProxy } from "./proxy/client" + +/** + * Extended Supabase client type with Money support + */ +export type CentSupabaseClient = SupabaseClient + +/** + * Create a Cent-enhanced Supabase client. + * + * This wraps the standard Supabase client to automatically: + * - Cast money columns to text in SELECT queries (preserving precision) + * - Transform response data into Money instances + * - Serialize Money instances in mutations + * - Handle realtime subscriptions + * + * @param supabaseUrl - Your Supabase project URL + * @param supabaseKey - Your Supabase anon/service key + * @param options - Cent configuration specifying money columns per table + * @param supabaseOptions - Options passed to underlying createClient + * @returns Enhanced Supabase client with Money support + * + * @example + * ```typescript + * const supabase = createCentSupabaseClient( + * process.env.SUPABASE_URL, + * process.env.SUPABASE_ANON_KEY, + * { + * tables: { + * orders: { + * money: { + * total: { currencyColumn: 'currency' }, + * tax: { currencyColumn: 'currency' } + * } + * }, + * products: { + * money: { + * price: { currencyCode: 'USD' } + * } + * } + * } + * } + * ); + * ``` + */ +export function createCentSupabaseClient( + supabaseUrl: string, + supabaseKey: string, + options: CentSupabaseOptions, + supabaseOptions?: Parameters[2], +): CentSupabaseClient { + // Create the underlying Supabase client + const client = createClient(supabaseUrl, supabaseKey, supabaseOptions) + + // Normalize the configuration + const config: NormalizedConfig = normalizeConfig(options) + + // Return proxied client with Money handling + return createClientProxy(client, config) as CentSupabaseClient +} + +/** + * Wrap an existing Supabase client with Money column handling. + * + * Use this when you already have a Supabase client instance and want + * to add Money handling to it. + * + * @param client - An existing Supabase client + * @param options - Cent configuration specifying money columns per table + * @returns Enhanced Supabase client with Money support + */ +export function wrapSupabaseClient( + client: SupabaseClient, + options: CentSupabaseOptions, +): CentSupabaseClient { + const config: NormalizedConfig = normalizeConfig(options) + return createClientProxy(client, config) as CentSupabaseClient +} diff --git a/packages/cent-supabase/src/helpers.ts b/packages/cent-supabase/src/helpers.ts new file mode 100644 index 0000000..4c479cd --- /dev/null +++ b/packages/cent-supabase/src/helpers.ts @@ -0,0 +1,149 @@ +import type { MoneyColumnConfig, NormalizedTableConfig } from "./types" +import { rewriteSelect } from "./transform/select" +import { transformResponseData } from "./transform/response" +import { serializeMoneyInData } from "./transform/mutation" + +/** + * Normalize a simple column config to NormalizedTableConfig + */ +function normalizeColumnConfig( + columns: Record, +): NormalizedTableConfig { + const moneyColumns = Object.keys(columns) + const money: NormalizedTableConfig["money"] = {} + + for (const [col, config] of Object.entries(columns)) { + if ("currencyColumn" in config) { + money[col] = { + currencyColumn: config.currencyColumn, + currencyCode: undefined, + minorUnits: config.minorUnits ?? false, + } + } else { + money[col] = { + currencyColumn: undefined, + currencyCode: config.currencyCode, + minorUnits: config.minorUnits ?? false, + } + } + } + + return { moneyColumns, money } +} + +/** + * Build a SELECT string with ::text casts for money columns. + * Useful for manual query building. + * + * @param columns - Column names to select (string or array) + * @param moneyColumns - Names of columns that are money + * @returns SELECT string with appropriate casts + * + * @example + * ```typescript + * moneySelect(['id', 'name', 'price'], ['price']) + * // Returns: "id, name, price::text" + * + * moneySelect('*', ['price', 'cost']) + * // Returns: "*, price::text as __cent_price, cost::text as __cent_cost" + * ``` + */ +export function moneySelect( + columns: string | string[], + moneyColumns: string[], +): string { + const colString = Array.isArray(columns) ? columns.join(", ") : columns + const config: NormalizedTableConfig = { + moneyColumns, + money: Object.fromEntries( + moneyColumns.map((col) => [ + col, + { currencyCode: "USD", currencyColumn: undefined, minorUnits: false }, + ]), + ), + } + return rewriteSelect(colString, config).select +} + +/** + * Transform response data by converting string amounts to Money instances. + * Useful for RPC results or when using the raw Supabase client. + * + * @param data - Response data from Supabase + * @param columns - Money column configurations + * @returns Transformed data with Money instances + * + * @example + * ```typescript + * const { data } = await supabase.rpc('calculate_total', { order_id: '...' }); + * const result = parseMoneyResult(data, { + * total: { currencyCode: 'USD' } + * }); + * ``` + */ +export function parseMoneyResult( + data: T, + columns: Record, +): T { + const config = normalizeColumnConfig(columns) + return transformResponseData(data, config, []) +} + +/** + * Serialize Money instances to strings for database mutations. + * Also auto-populates currency columns from Money instances. + * + * @param data - Data containing Money instances + * @param columns - Money column configurations + * @returns Serialized data ready for insert/update + * + * @example + * ```typescript + * const serialized = serializeMoney( + * { total: Money('€150.00'), name: 'Order 1' }, + * { total: { currencyColumn: 'currency' } } + * ); + * // Returns: { total: '150.00', currency: 'EUR', name: 'Order 1' } + * ``` + */ +export function serializeMoney( + data: T, + columns: Record, +): T { + const config = normalizeColumnConfig(columns) + return serializeMoneyInData(data, config) +} + +/** + * Transform a realtime payload by converting money columns to Money instances. + * Useful when using raw channel subscriptions. + * + * @param payload - Realtime payload from Supabase + * @param config - Table configuration with money columns + * @returns Transformed payload with Money instances + * + * @example + * ```typescript + * channel.on('postgres_changes', { ... }, (payload) => { + * const transformed = transformRealtimePayload(payload, { + * money: { price: { currencyCode: 'USD' } } + * }); + * }); + * ``` + */ +export function transformRealtimePayload( + payload: T, + config: { money: Record }, +): T { + const tableConfig = normalizeColumnConfig(config.money) + const result = { ...payload } + + if (result.new) { + result.new = transformResponseData(result.new, tableConfig, []) + } + if (result.old) { + result.old = transformResponseData(result.old, tableConfig, []) + } + + return result +} diff --git a/packages/cent-supabase/src/index.ts b/packages/cent-supabase/src/index.ts new file mode 100644 index 0000000..6a61f84 --- /dev/null +++ b/packages/cent-supabase/src/index.ts @@ -0,0 +1,27 @@ +/** + * @thesis-co/cent-supabase + * + * Precision-safe money handling for Supabase/PostgREST. + * Automatically handles DECIMAL/NUMERIC columns to prevent JavaScript + * floating-point precision loss. + */ + +// Types +export type { + CentSupabaseOptions, + CurrencySource, + MoneyColumnConfig, + TableConfig, +} from "./types" + +// Factory functions +export { createCentSupabaseClient, wrapSupabaseClient } from "./client" +export type { CentSupabaseClient } from "./client" + +// Helper functions for manual use +export { + moneySelect, + parseMoneyResult, + serializeMoney, + transformRealtimePayload, +} from "./helpers" diff --git a/packages/cent-supabase/src/proxy/client.ts b/packages/cent-supabase/src/proxy/client.ts new file mode 100644 index 0000000..35c52cf --- /dev/null +++ b/packages/cent-supabase/src/proxy/client.ts @@ -0,0 +1,48 @@ +import type { SupabaseClient } from "@supabase/supabase-js" +import type { NormalizedConfig } from "../types" +import { createQueryBuilderProxy } from "./query-builder" +import { createRealtimeChannelProxy } from "./realtime" + +/** + * Create a proxied Supabase client that handles Money columns + */ +export function createClientProxy>( + client: T, + config: NormalizedConfig, +): T { + return new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept from() + if (prop === "from") { + return (tableName: string) => { + const queryBuilder = target.from(tableName) + const tableConfig = config.tables[tableName] + + // If no config for this table, return the original query builder + if (!tableConfig) { + return queryBuilder + } + + return createQueryBuilderProxy(queryBuilder, tableConfig) + } + } + + // Intercept channel() for realtime + if (prop === "channel") { + return (name: string, opts?: any) => { + const channel = target.channel(name, opts) + return createRealtimeChannelProxy(channel, config) + } + } + + // Pass through other methods + return value.bind(target) + } + + return value + }, + }) +} diff --git a/packages/cent-supabase/src/proxy/query-builder.ts b/packages/cent-supabase/src/proxy/query-builder.ts new file mode 100644 index 0000000..e661f9b --- /dev/null +++ b/packages/cent-supabase/src/proxy/query-builder.ts @@ -0,0 +1,153 @@ +import type { NormalizedTableConfig } from "../types" +import { rewriteSelect, type RewriteSelectResult } from "../transform/select" +import { transformResponseData } from "../transform/response" +import { serializeMoneyInData } from "../transform/mutation" + +// Query builder type - using interface for the methods we need +interface QueryBuilder { + select(columns: string, options?: unknown): unknown + insert(data: unknown, options?: unknown): unknown + update(data: unknown, options?: unknown): unknown + upsert(data: unknown, options?: unknown): unknown +} + +/** + * Create a proxied query builder that handles Money columns + */ +export function createQueryBuilderProxy( + queryBuilder: T, + tableConfig: NormalizedTableConfig, +): T { + return new Proxy(queryBuilder as object, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept select() + if (prop === "select") { + return createSelectInterceptor(target as QueryBuilder, tableConfig) + } + + // Intercept mutation methods + if (prop === "insert" || prop === "upsert") { + return createInsertInterceptor(target as QueryBuilder, prop, tableConfig) + } + + if (prop === "update") { + return createUpdateInterceptor(target as QueryBuilder, tableConfig) + } + + // Pass through other methods, but wrap the result + return (...args: unknown[]) => { + const result = (value as Function).apply(target, args) + if (result && typeof result === "object" && "then" in result) { + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } + return result + } + } + + return value + }, + }) as T +} + +/** + * Create a select() interceptor that rewrites the SELECT clause + */ +function createSelectInterceptor( + target: QueryBuilder, + tableConfig: NormalizedTableConfig, +) { + return (columns?: string, options?: { head?: boolean; count?: "exact" | "planned" | "estimated" }) => { + const columnsStr = columns ?? "*" + const rewriteResult = rewriteSelect(columnsStr, tableConfig) + + const result = target.select(rewriteResult.select, options) + return createFilterBuilderProxy(result, tableConfig, rewriteResult) + } +} + +/** + * Create an insert/upsert interceptor that serializes Money values + */ +function createInsertInterceptor( + target: QueryBuilder, + method: "insert" | "upsert", + tableConfig: NormalizedTableConfig, +) { + return (data: unknown, options?: unknown) => { + const serialized = serializeMoneyInData(data, tableConfig) + const result = target[method](serialized, options) + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } +} + +/** + * Create an update interceptor that serializes Money values + */ +function createUpdateInterceptor( + target: QueryBuilder, + tableConfig: NormalizedTableConfig, +) { + return (data: unknown, options?: unknown) => { + const serialized = serializeMoneyInData(data, tableConfig) + const result = target.update(serialized, options) + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } +} + +/** + * Create a proxied filter builder that transforms response data + */ +export function createFilterBuilderProxy( + filterBuilder: T, + tableConfig: NormalizedTableConfig, + selectResult: RewriteSelectResult, +): T { + return new Proxy(filterBuilder as object, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept select() on filter builder (for chained selects) + if (prop === "select") { + return (columns?: string, options?: unknown) => { + const columnsStr = columns ?? "*" + const newSelectResult = rewriteSelect(columnsStr, tableConfig) + const result = (target as QueryBuilder).select(newSelectResult.select, options) + return createFilterBuilderProxy(result, tableConfig, newSelectResult) + } + } + + // Intercept then() to transform response + if (prop === "then") { + return (onfulfilled?: (value: unknown) => unknown, onrejected?: (reason: unknown) => unknown) => { + return (value as Function).call(target, (response: { data?: unknown }) => { + if (response && response.data) { + response.data = transformResponseData( + response.data, + tableConfig, + selectResult.tempColumns, + ) + } + return onfulfilled ? onfulfilled(response) : response + }, onrejected) + } + } + + // Pass through other methods, wrapping the result + return (...args: unknown[]) => { + const result = (value as Function).apply(target, args) + // If result is thenable, wrap it + if (result && typeof result === "object" && "then" in result) { + return createFilterBuilderProxy(result, tableConfig, selectResult) + } + return result + } + } + + return value + }, + }) as T +} diff --git a/packages/cent-supabase/src/proxy/realtime.ts b/packages/cent-supabase/src/proxy/realtime.ts new file mode 100644 index 0000000..40ed2f9 --- /dev/null +++ b/packages/cent-supabase/src/proxy/realtime.ts @@ -0,0 +1,65 @@ +import type { RealtimeChannel } from "@supabase/supabase-js" +import type { NormalizedConfig } from "../types" +import { transformResponseData } from "../transform/response" + +/** + * Create a proxied realtime channel that transforms Money columns in payloads + */ +export function createRealtimeChannelProxy( + channel: T, + config: NormalizedConfig, +): T { + return new Proxy(channel, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept on() for postgres_changes + if (prop === "on") { + return ( + type: string, + filter: { event: string; schema?: string; table?: string; filter?: string }, + callback: (payload: any) => void, + ) => { + // Only intercept postgres_changes + if (type === "postgres_changes" && filter.table) { + const tableConfig = config.tables[filter.table] + + if (tableConfig) { + // Wrap the callback to transform Money columns + const wrappedCallback = (payload: any) => { + if (payload.new) { + payload.new = transformResponseData(payload.new, tableConfig, []) + } + if (payload.old) { + payload.old = transformResponseData(payload.old, tableConfig, []) + } + callback(payload) + } + + const result = target.on(type as any, filter as any, wrappedCallback) + return createRealtimeChannelProxy(result as T, config) + } + } + + // Pass through for non-configured tables or other event types + const result = target.on(type as any, filter as any, callback) + return createRealtimeChannelProxy(result as T, config) + } + } + + // Pass through other methods, wrapping the result if it returns the channel + return (...args: unknown[]) => { + const result = value.apply(target, args) + // If result is the channel (for chaining), wrap it + if (result === target || (result && typeof result === "object" && "on" in result)) { + return createRealtimeChannelProxy(result as T, config) + } + return result + } + } + + return value + }, + }) +} diff --git a/packages/cent-supabase/src/transform/mutation.ts b/packages/cent-supabase/src/transform/mutation.ts new file mode 100644 index 0000000..be1c492 --- /dev/null +++ b/packages/cent-supabase/src/transform/mutation.ts @@ -0,0 +1,105 @@ +import { + MoneyClass, + type MoneyClass as Money, +} from "@thesis-co/cent" +import type { NormalizedTableConfig, NormalizedMoneyColumnConfig } from "../types" + +/** + * Serialize Money instances in mutation data to strings for database insertion. + * + * For each money column: + * 1. If the value is a Money instance: + * - Convert to decimal string (or minor units string if minorUnits: true) + * - Auto-populate the currency column from Money's currency code (if currencyColumn config) + * 2. Pass through non-Money values unchanged + * + * @param data - Data to be inserted/updated (single row or array) + * @param tableConfig - Normalized table configuration + * @returns Serialized data with Money instances converted to strings + */ +export function serializeMoneyInData( + data: T, + tableConfig: NormalizedTableConfig, +): T { + // Handle null/undefined + if (data == null) { + return data + } + + // Handle arrays + if (Array.isArray(data)) { + return data.map((row) => + serializeRow(row as Record, tableConfig), + ) as T + } + + // Handle single object + if (typeof data === "object") { + return serializeRow( + data as Record, + tableConfig, + ) as T + } + + return data +} + +/** + * Serialize a single row of data, converting Money instances to strings + */ +export function serializeRow( + row: Record, + tableConfig: NormalizedTableConfig, +): Record { + const result = { ...row } + + // Track currency column values to detect conflicts + const currencyColumnValues: Record = {} + + // Process each money column + for (const columnName of tableConfig.moneyColumns) { + const config = tableConfig.money[columnName] + const value = result[columnName] + + if (MoneyClass.isMoney(value)) { + // Serialize Money to string + result[columnName] = serializeMoneyValue(value, config.minorUnits) + + // Auto-populate currency column if configured + if (config.currencyColumn) { + const currencyCode = value.currency.code + + // Check for conflicts with other Money values targeting same currency column + if (config.currencyColumn in currencyColumnValues) { + const existingCurrency = currencyColumnValues[config.currencyColumn] + if (existingCurrency !== currencyCode) { + throw new Error( + `Conflicting currencies for column '${config.currencyColumn}': ` + + `${existingCurrency} vs ${currencyCode}`, + ) + } + } + + // Set currency column value + currencyColumnValues[config.currencyColumn] = currencyCode + result[config.currencyColumn] = currencyCode + } + } + // Non-Money values pass through unchanged + } + + return result +} + +/** + * Serialize a Money value to a string representation for database storage + */ +export function serializeMoneyValue( + money: Money, + minorUnits: boolean, +): string { + if (minorUnits) { + return money.toMinorUnits().toString() + } + return money.toDecimalString() +} diff --git a/packages/cent-supabase/src/transform/response.ts b/packages/cent-supabase/src/transform/response.ts new file mode 100644 index 0000000..0dbdcbe --- /dev/null +++ b/packages/cent-supabase/src/transform/response.ts @@ -0,0 +1,142 @@ +import { + Money as MoneyFactory, + type MoneyClass as Money, +} from "@thesis-co/cent" +import type { NormalizedTableConfig, NormalizedMoneyColumnConfig } from "../types" +import { getOriginalColumnName, isTempColumn, TEMP_COLUMN_PREFIX } from "./select" + +/** + * Transform response data by converting string amounts to Money instances. + * + * Handles two patterns: + * 1. Temp columns from SELECT * (e.g., `__cent_price` → use value for `price`) + * 2. Direct string values from explicit SELECT (e.g., `price: "100.50"`) + * + * @param data - Response data from Supabase (single row or array) + * @param tableConfig - Normalized table configuration + * @param tempColumns - List of temporary column names to process and remove + * @returns Transformed data with Money instances + */ +export function transformResponseData( + data: T, + tableConfig: NormalizedTableConfig, + tempColumns: string[] = [], +): T { + // Handle null/undefined + if (data == null) { + return data + } + + // Handle arrays + if (Array.isArray(data)) { + return data.map((row) => + transformRow(row as Record, tableConfig, tempColumns), + ) as T + } + + // Handle single object + if (typeof data === "object") { + return transformRow( + data as Record, + tableConfig, + tempColumns, + ) as T + } + + return data +} + +/** + * Transform a single row of data + */ +export function transformRow( + row: Record, + tableConfig: NormalizedTableConfig, + tempColumns: string[] = [], +): Record { + const result = { ...row } + const tempColumnSet = new Set(tempColumns) + + // Process each money column + for (const columnName of tableConfig.moneyColumns) { + const config = tableConfig.money[columnName] + const tempColumnName = `${TEMP_COLUMN_PREFIX}${columnName}` + + // Check if we have a temp column (SELECT * pattern) + if (tempColumnSet.has(tempColumnName) && tempColumnName in result) { + const value = result[tempColumnName] + if (typeof value === "string") { + const currencyCode = getCurrencyCode(result, config) + if (currencyCode) { + result[columnName] = createMoneyFromValue( + value, + currencyCode, + config.minorUnits, + ) + } + } + // Remove temp column + delete result[tempColumnName] + } + // Check for direct string value (explicit SELECT pattern) + else if (columnName in result) { + const value = result[columnName] + if (typeof value === "string") { + const currencyCode = getCurrencyCode(result, config) + if (currencyCode) { + result[columnName] = createMoneyFromValue( + value, + currencyCode, + config.minorUnits, + ) + } + } + // Non-string values (numbers, null, undefined) pass through unchanged + } + } + + // Clean up any remaining temp columns not in our config + for (const key of Object.keys(result)) { + if (isTempColumn(key)) { + delete result[key] + } + } + + return result +} + +/** + * Get the currency code for a money column from either config or row data + */ +function getCurrencyCode( + row: Record, + config: NormalizedMoneyColumnConfig, +): string | undefined { + if (config.currencyCode) { + return config.currencyCode + } + if (config.currencyColumn) { + const currency = row[config.currencyColumn] + if (typeof currency === "string") { + return currency + } + } + return undefined +} + +/** + * Create a Money instance from a string value and column config + */ +export function createMoneyFromValue( + value: string, + currencyCode: string, + minorUnits: boolean, +): Money { + if (minorUnits) { + // Value is in minor units (cents, satoshis), convert to Money + const minorAmount = BigInt(value) + return MoneyFactory(minorAmount, currencyCode) + } + // Value is a decimal string + return MoneyFactory(`${value} ${currencyCode}`) +} diff --git a/packages/cent-supabase/src/transform/select.ts b/packages/cent-supabase/src/transform/select.ts new file mode 100644 index 0000000..9b6be0b --- /dev/null +++ b/packages/cent-supabase/src/transform/select.ts @@ -0,0 +1,235 @@ +import type { NormalizedTableConfig } from "../types" + +/** + * Result of rewriting a SELECT clause + */ +export interface RewriteSelectResult { + /** The rewritten SELECT string */ + select: string + /** Temporary column names that need cleanup in response (for SELECT * pattern) */ + tempColumns: string[] +} + +/** + * Rewrite a SELECT clause to cast money columns to text for precision safety. + * + * For `SELECT *`: + * - Keeps the `*` + * - Appends `::text` casts with temp aliases for money columns + * - Example: `*` → `*, price::text as __cent_price` + * + * For explicit column lists: + * - Casts money columns directly + * - Example: `id, price` → `id, price::text` + * + * @param columns - The original SELECT columns string + * @param tableConfig - Normalized table configuration + * @returns The rewritten SELECT string and list of temp columns + */ +export function rewriteSelect( + columns: string, + tableConfig: NormalizedTableConfig, +): RewriteSelectResult { + const trimmed = columns.trim() + + // Handle empty string + if (!trimmed) { + return { select: "", tempColumns: [] } + } + + // Handle no money columns + if (tableConfig.moneyColumns.length === 0) { + return { select: trimmed, tempColumns: [] } + } + + // Handle SELECT * pattern + if (trimmed === "*") { + const tempColumns: string[] = [] + const casts = tableConfig.moneyColumns.map((col) => { + const tempName = getTempColumnName(col) + tempColumns.push(tempName) + return `${col}::text as ${tempName}` + }) + return { + select: `*, ${casts.join(", ")}`, + tempColumns, + } + } + + // Handle explicit column list + const result = rewriteExplicitColumns(trimmed, tableConfig.moneyColumns) + return { select: result, tempColumns: [] } +} + +/** + * Aggregate functions that should be cast when containing money columns + */ +const AGGREGATE_FUNCTIONS = ["sum", "avg", "min", "max"] + +/** + * Rewrite explicit column list, casting money columns to text + */ +function rewriteExplicitColumns( + columns: string, + moneyColumns: string[], +): string { + // Split by comma, preserving nested parentheses + const parts = splitColumns(columns) + + const rewritten = parts.map((part) => { + const trimmedPart = part.trim() + + // Skip if already has a cast (::) + if (hasCast(trimmedPart)) { + return trimmedPart + } + + // Check for aggregate functions + const aggregateMatch = matchAggregate(trimmedPart) + if (aggregateMatch) { + const { func, inner, alias } = aggregateMatch + // Check if inner column is a money column + if (moneyColumns.includes(inner)) { + const cast = `${func}(${inner})::text` + return alias ? `${cast}${alias}` : cast + } + return trimmedPart + } + + // Skip nested relations (contains parentheses but not an aggregate) + if (trimmedPart.includes("(")) { + return trimmedPart + } + + // Check for alias + const aliasMatch = matchAlias(trimmedPart) + if (aliasMatch) { + const { column, alias } = aliasMatch + if (moneyColumns.includes(column)) { + return `${column}::text${alias}` + } + return trimmedPart + } + + // Simple column - check if it's a money column (exact match) + if (moneyColumns.includes(trimmedPart)) { + return `${trimmedPart}::text` + } + + return trimmedPart + }) + + return rewritten.join(", ") +} + +/** + * Split columns by comma, respecting parentheses + */ +function splitColumns(columns: string): string[] { + const parts: string[] = [] + let current = "" + let depth = 0 + + for (const char of columns) { + if (char === "(") { + depth++ + current += char + } else if (char === ")") { + depth-- + current += char + } else if (char === "," && depth === 0) { + parts.push(current) + current = "" + } else { + current += char + } + } + + if (current) { + parts.push(current) + } + + return parts +} + +/** + * Check if a column expression already has a cast + */ +function hasCast(expr: string): boolean { + // Look for :: not inside parentheses + let depth = 0 + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++ + else if (expr[i] === ")") depth-- + else if (depth === 0 && expr[i] === ":" && expr[i + 1] === ":") { + return true + } + } + return false +} + +/** + * Match aggregate function pattern: func(column) [as alias] + */ +function matchAggregate( + expr: string, +): { func: string; inner: string; alias: string } | null { + const pattern = new RegExp( + `^(${AGGREGATE_FUNCTIONS.join("|")})\\s*\\(\\s*([^)]+)\\s*\\)(\\s+(?:as\\s+)?\\w+)?$`, + "i", + ) + const match = expr.match(pattern) + if (match) { + return { + func: match[1].toLowerCase(), + inner: match[2].trim(), + alias: match[3] || "", + } + } + return null +} + +/** + * Match alias pattern: column [as] alias + */ +function matchAlias(expr: string): { column: string; alias: string } | null { + // Match "column as alias" or "column alias" (but not aggregates) + const pattern = /^(\w+)\s+(as\s+\w+|AS\s+\w+)$/ + const match = expr.match(pattern) + if (match) { + return { + column: match[1], + alias: ` ${match[2]}`, + } + } + return null +} + +/** + * Prefix for temporary columns created during SELECT * rewriting + */ +export const TEMP_COLUMN_PREFIX = "__cent_" + +/** + * Get the temporary column name for a money column + */ +export function getTempColumnName(columnName: string): string { + return `${TEMP_COLUMN_PREFIX}${columnName}` +} + +/** + * Check if a column name is a temporary cent column + */ +export function isTempColumn(columnName: string): boolean { + return columnName.startsWith(TEMP_COLUMN_PREFIX) +} + +/** + * Get the original column name from a temporary column name + */ +export function getOriginalColumnName(tempColumnName: string): string { + if (!isTempColumn(tempColumnName)) { + return tempColumnName + } + return tempColumnName.slice(TEMP_COLUMN_PREFIX.length) +} diff --git a/packages/cent-supabase/src/types.ts b/packages/cent-supabase/src/types.ts new file mode 100644 index 0000000..f1e5485 --- /dev/null +++ b/packages/cent-supabase/src/types.ts @@ -0,0 +1,133 @@ +/** + * Configuration for a money column with dynamic currency (from another column) + */ +export interface DynamicCurrencyConfig { + /** Column name containing the currency code */ + currencyColumn: string + /** + * Whether values are stored in minor units (cents, satoshis, wei). + * @default false + */ + minorUnits?: boolean +} + +/** + * Configuration for a money column with static currency + */ +export interface StaticCurrencyConfig { + /** Static currency code (e.g., 'USD', 'EUR', 'BTC') */ + currencyCode: string + /** + * Whether values are stored in minor units (cents, satoshis, wei). + * @default false + */ + minorUnits?: boolean +} + +/** + * Configuration for a money column - either dynamic or static currency + */ +export type MoneyColumnConfig = DynamicCurrencyConfig | StaticCurrencyConfig + +/** + * Currency source type for convenience + */ +export type CurrencySource = MoneyColumnConfig + +/** + * Configuration for a single table + */ +export interface TableConfig { + /** + * Money column configurations. + * Key is the column name, value is the currency configuration. + */ + money: Record +} + +/** + * Options for creating a Cent-enhanced Supabase client + */ +export interface CentSupabaseOptions { + /** + * Table configurations. + * Key is the table name, value is the table configuration. + */ + tables: Record +} + +/** + * Internal normalized configuration + */ +export interface NormalizedConfig { + tables: Record +} + +/** + * Normalized table configuration with computed properties + */ +export interface NormalizedTableConfig { + money: Record + /** List of money column names for quick lookup */ + moneyColumns: string[] +} + +/** + * Normalized money column configuration + */ +export interface NormalizedMoneyColumnConfig { + /** Currency column name (for dynamic currency) */ + currencyColumn?: string + /** Static currency code */ + currencyCode?: string + /** Whether stored in minor units */ + minorUnits: boolean +} + +/** + * Type guard to check if a currency source uses a column + */ +export function hasCurrencyColumn( + config: MoneyColumnConfig, +): config is DynamicCurrencyConfig { + return "currencyColumn" in config +} + +/** + * Type guard to check if a currency source uses a static code + */ +export function hasCurrencyCode( + config: MoneyColumnConfig, +): config is StaticCurrencyConfig { + return "currencyCode" in config +} + +/** + * Normalize user-provided options into internal config format + */ +export function normalizeConfig(options: CentSupabaseOptions): NormalizedConfig { + const tables: Record = {} + + for (const [tableName, tableConfig] of Object.entries(options.tables)) { + const money: Record = {} + const moneyColumns: string[] = [] + + for (const [columnName, columnConfig] of Object.entries(tableConfig.money)) { + moneyColumns.push(columnName) + + money[columnName] = { + currencyColumn: hasCurrencyColumn(columnConfig) + ? columnConfig.currencyColumn + : undefined, + currencyCode: hasCurrencyCode(columnConfig) + ? columnConfig.currencyCode + : undefined, + minorUnits: columnConfig.minorUnits ?? false, + } + } + + tables[tableName] = { money, moneyColumns } + } + + return { tables } +} diff --git a/packages/cent-supabase/test/mutation.test.ts b/packages/cent-supabase/test/mutation.test.ts new file mode 100644 index 0000000..24f97f0 --- /dev/null +++ b/packages/cent-supabase/test/mutation.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, MoneyClass } from "@thesis-co/cent" +import { + serializeMoneyInData, + serializeMoneyValue, + serializeRow, +} from "../src/transform/mutation" +import type { NormalizedTableConfig } from "../src/types" + +describe("mutation serialization", () => { + // Helper to create a table config + const createConfig = ( + columns: Record< + string, + { currencyCode?: string; currencyColumn?: string; minorUnits?: boolean } + >, + ): NormalizedTableConfig => ({ + moneyColumns: Object.keys(columns), + money: Object.fromEntries( + Object.entries(columns).map(([col, config]) => [ + col, + { + currencyCode: config.currencyCode, + currencyColumn: config.currencyColumn, + minorUnits: config.minorUnits ?? false, + }, + ]), + ), + }) + + describe("serializeMoneyInData", () => { + describe("basic Money serialization", () => { + it("serializes Money instance to decimal string", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: Money("$99.99"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("99.99") + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("serializes multiple Money columns", () => { + const config = createConfig({ + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }) + const data = { + id: 1, + price: Money("$100.00"), + cost: Money("$50.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("100.00") + expect(result.cost).toBe("50.00") + }) + + it("passes through non-Money values unchanged", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: "99.99", // Already a string + name: "Widget", + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("99.99") + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("passes through numeric values unchanged", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: 99.99, // Number + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe(99.99) + }) + }) + + describe("currency column auto-population", () => { + it("auto-populates currency column from Money instance", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: Money("€150.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.total).toBe("150.00") + expect(result.currency).toBe("EUR") + }) + + it("auto-populates multiple currency columns", () => { + const config = createConfig({ + subtotal: { currencyColumn: "subtotal_currency" }, + shipping: { currencyColumn: "shipping_currency" }, + }) + const data = { + id: 1, + subtotal: Money("€100.00"), + shipping: Money("$10.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.subtotal).toBe("100.00") + expect(result.subtotal_currency).toBe("EUR") + expect(result.shipping).toBe("10.00") + expect(result.shipping_currency).toBe("USD") + }) + + it("does not override existing currency column value", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: Money("€150.00"), + currency: "GBP", // Explicitly set (should warn or override?) + } + + // Implementation note: Could warn or throw if currency doesn't match + // For now, the Money's currency should take precedence + const result = serializeMoneyInData(data, config) + + expect(result.currency).toBe("EUR") // Money takes precedence + }) + + it("handles shared currency column with matching currencies", () => { + const config = createConfig({ + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }) + const data = { + id: 1, + total: Money("€150.00"), + tax: Money("€15.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.total).toBe("150.00") + expect(result.tax).toBe("15.00") + expect(result.currency).toBe("EUR") + }) + + it("throws error for conflicting currencies on shared currency column", () => { + const config = createConfig({ + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }) + const data = { + id: 1, + total: Money("€150.00"), + tax: Money("$15.00"), // Different currency! + } + + expect(() => serializeMoneyInData(data, config)).toThrow( + /conflicting currencies/i, + ) + }) + }) + + describe("minor units mode", () => { + it("serializes to minor units when configured", () => { + const config = createConfig({ + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }) + const data = { + id: 1, + balance_sats: Money("1.5 BTC"), // 150000000 satoshis + } + + const result = serializeMoneyInData(data, config) + + expect(result.balance_sats).toBe("150000000") + }) + + it("serializes cents correctly", () => { + const config = createConfig({ + amount_cents: { currencyCode: "USD", minorUnits: true }, + }) + const data = { + id: 1, + amount_cents: Money("$100.50"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount_cents).toBe("10050") + }) + + it("handles minor units with currency column", () => { + const config = createConfig({ + amount_minor: { currencyColumn: "currency", minorUnits: true }, + }) + const data = { + id: 1, + amount_minor: Money("¥1000"), // JPY has 0 decimals + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount_minor).toBe("1000") + expect(result.currency).toBe("JPY") + }) + }) + + describe("array handling", () => { + it("serializes array of rows", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = [ + { id: 1, price: Money("$10.00") }, + { id: 2, price: Money("$20.00") }, + { id: 3, price: Money("$30.00") }, + ] + + const result = serializeMoneyInData(data, config) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(3) + expect(result[0].price).toBe("10.00") + expect(result[1].price).toBe("20.00") + expect(result[2].price).toBe("30.00") + }) + + it("handles empty array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const result = serializeMoneyInData([], config) + + expect(result).toEqual([]) + }) + }) + + describe("null and undefined handling", () => { + it("preserves null values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, price: null } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBeNull() + }) + + it("preserves undefined values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, name: "Test" } // price not included + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBeUndefined() + }) + }) + + describe("precision preservation", () => { + it("preserves large number precision", () => { + const config = createConfig({ amount: { currencyCode: "USD" } }) + const data = { + id: 1, + amount: Money("900719925474099.28 USD"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount).toBe("900719925474099.28") + }) + + it("preserves crypto precision (18 decimals)", () => { + const config = createConfig({ balance: { currencyCode: "ETH" } }) + const data = { + id: 1, + balance: Money("123456789.123456789012345678 ETH"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.balance).toBe("123456789.123456789012345678") + }) + }) + }) + + describe("serializeRow", () => { + it("serializes a single row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: Money("$50.00") } + + const result = serializeRow(row, config) + + expect(result.price).toBe("50.00") + }) + + it("does not mutate original row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const originalMoney = Money("$50.00") + const row = { id: 1, price: originalMoney } + + serializeRow(row, config) + + expect(row.price).toBe(originalMoney) + expect(MoneyClass.isMoney(row.price)).toBe(true) + }) + }) + + describe("serializeMoneyValue", () => { + it("serializes to decimal string by default", () => { + const money = Money("$100.50") + const result = serializeMoneyValue(money, false) + + expect(result).toBe("100.50") + }) + + it("serializes to minor units when requested", () => { + const money = Money("$100.50") + const result = serializeMoneyValue(money, true) + + expect(result).toBe("10050") + }) + + it("handles BTC satoshis", () => { + const money = Money("1.5 BTC") + const result = serializeMoneyValue(money, true) + + expect(result).toBe("150000000") + }) + + it("preserves precision in decimal string", () => { + const money = Money("123456789.123456789012345678 ETH") + const result = serializeMoneyValue(money, false) + + expect(result).toBe("123456789.123456789012345678") + }) + }) +}) diff --git a/packages/cent-supabase/test/response.test.ts b/packages/cent-supabase/test/response.test.ts new file mode 100644 index 0000000..cbd1eef --- /dev/null +++ b/packages/cent-supabase/test/response.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, MoneyClass } from "@thesis-co/cent" +import { transformResponseData, transformRow } from "../src/transform/response" +import type { NormalizedTableConfig } from "../src/types" + +describe("response transformation", () => { + // Helper to create a table config + const createConfig = ( + columns: Record< + string, + { currencyCode?: string; currencyColumn?: string; minorUnits?: boolean } + >, + ): NormalizedTableConfig => ({ + moneyColumns: Object.keys(columns), + money: Object.fromEntries( + Object.entries(columns).map(([col, config]) => [ + col, + { + currencyCode: config.currencyCode, + currencyColumn: config.currencyColumn, + minorUnits: config.minorUnits ?? false, + }, + ]), + ), + }) + + describe("transformResponseData", () => { + describe("with temp columns (SELECT * pattern)", () => { + it("transforms temp column values to Money and removes temp columns", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: 99.99, // Original (precision lost) + __cent_price: "99.99", // Temp column (precision preserved) + } + + const result = transformResponseData(data, config, ["__cent_price"]) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect((result.price as MoneyClass).toDecimalString()).toBe("99.99") + expect(result.__cent_price).toBeUndefined() + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("handles multiple temp columns", () => { + const config = createConfig({ + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }) + const data = { + id: 1, + price: 100, + cost: 50, + __cent_price: "100.00", + __cent_cost: "50.00", + } + + const result = transformResponseData(data, config, [ + "__cent_price", + "__cent_cost", + ]) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect(MoneyClass.isMoney(result.cost)).toBe(true) + expect(result.__cent_price).toBeUndefined() + expect(result.__cent_cost).toBeUndefined() + }) + }) + + describe("with direct string values (explicit SELECT pattern)", () => { + it("transforms string values to Money", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: "99.99", // Already a string from ::text cast + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect((result.price as MoneyClass).toDecimalString()).toBe("99.99") + }) + + it("leaves non-string money columns unchanged (no cast was applied)", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: 99.99, // Number - wasn't cast, don't transform + } + + const result = transformResponseData(data, config) + + // Number stays as number (user didn't cast it) + expect(result.price).toBe(99.99) + expect(MoneyClass.isMoney(result.price)).toBe(false) + }) + }) + + describe("with dynamic currency (currencyColumn)", () => { + it("reads currency from specified column", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: "150.00", + currency: "EUR", + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.total)).toBe(true) + expect((result.total as MoneyClass).currency.code).toBe("EUR") + }) + + it("handles different currencies per row", () => { + const config = createConfig({ amount: { currencyColumn: "currency" } }) + const rows = [ + { id: 1, amount: "100.00", currency: "USD" }, + { id: 2, amount: "200.00", currency: "EUR" }, + { id: 3, amount: "1.5", currency: "BTC" }, + ] + + const result = transformResponseData(rows, config) + + expect((result[0].amount as MoneyClass).currency.code).toBe("USD") + expect((result[1].amount as MoneyClass).currency.code).toBe("EUR") + expect((result[2].amount as MoneyClass).currency.code).toBe("BTC") + }) + }) + + describe("with minor units", () => { + it("converts minor units to Money", () => { + const config = createConfig({ + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }) + const data = { + id: 1, + balance_sats: "150000000", // 1.5 BTC in satoshis + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.balance_sats)).toBe(true) + expect((result.balance_sats as MoneyClass).toDecimalString()).toBe("1.50000000") + }) + + it("handles cents as minor units", () => { + const config = createConfig({ + amount_cents: { currencyCode: "USD", minorUnits: true }, + }) + const data = { + id: 1, + amount_cents: "10050", // $100.50 in cents + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.amount_cents)).toBe(true) + expect((result.amount_cents as MoneyClass).toDecimalString()).toBe("100.50") + }) + }) + + describe("with arrays of rows", () => { + it("transforms all rows in array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = [ + { id: 1, price: "10.00" }, + { id: 2, price: "20.00" }, + { id: 3, price: "30.00" }, + ] + + const result = transformResponseData(data, config) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(3) + result.forEach((row) => { + expect(MoneyClass.isMoney(row.price)).toBe(true) + }) + }) + + it("handles empty array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const result = transformResponseData([], config) + + expect(result).toEqual([]) + }) + }) + + describe("null and undefined handling", () => { + it("preserves null values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, price: null } + + const result = transformResponseData(data, config) + + expect(result.price).toBeNull() + }) + + it("preserves undefined values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, name: "Test" } // price not included + + const result = transformResponseData(data, config) + + expect(result.price).toBeUndefined() + }) + }) + + describe("large number precision", () => { + it("preserves precision for large numbers", () => { + const config = createConfig({ amount: { currencyCode: "USD" } }) + const data = { + id: 1, + amount: "900719925474099.28", // Larger than MAX_SAFE_INTEGER + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.amount)).toBe(true) + expect((result.amount as MoneyClass).toDecimalString()).toBe( + "900719925474099.28", + ) + }) + + it("preserves precision for crypto amounts", () => { + const config = createConfig({ balance: { currencyCode: "ETH" } }) + const data = { + id: 1, + balance: "123456789.123456789012345678", // 18 decimals + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.balance)).toBe(true) + // Should preserve all 18 decimals + expect((result.balance as MoneyClass).toDecimalString()).toBe( + "123456789.123456789012345678", + ) + }) + }) + }) + + describe("transformRow", () => { + it("transforms a single row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: "50.00" } + + const result = transformRow(row, config) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + }) + + it("does not mutate original row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: "50.00" } + + transformRow(row, config) + + expect(row.price).toBe("50.00") + }) + }) +}) diff --git a/packages/cent-supabase/test/select.test.ts b/packages/cent-supabase/test/select.test.ts new file mode 100644 index 0000000..a2a1db4 --- /dev/null +++ b/packages/cent-supabase/test/select.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "@jest/globals" +import { + getOriginalColumnName, + getTempColumnName, + isTempColumn, + rewriteSelect, + TEMP_COLUMN_PREFIX, +} from "../src/transform/select" +import type { NormalizedTableConfig } from "../src/types" + +describe("SELECT rewriting", () => { + // Helper to create a simple table config + const createConfig = ( + moneyColumns: string[], + ): NormalizedTableConfig => ({ + moneyColumns, + money: Object.fromEntries( + moneyColumns.map((col) => [ + col, + { currencyCode: "USD", minorUnits: false }, + ]), + ), + }) + + describe("rewriteSelect", () => { + describe("SELECT * handling", () => { + it("appends temp columns for money columns with SELECT *", () => { + const config = createConfig(["price", "cost"]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe( + "*, price::text as __cent_price, cost::text as __cent_cost", + ) + expect(result.tempColumns).toEqual(["__cent_price", "__cent_cost"]) + }) + + it("handles single money column with SELECT *", () => { + const config = createConfig(["amount"]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe("*, amount::text as __cent_amount") + expect(result.tempColumns).toEqual(["__cent_amount"]) + }) + + it("returns unchanged * when no money columns", () => { + const config = createConfig([]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe("*") + expect(result.tempColumns).toEqual([]) + }) + + it("handles * with whitespace", () => { + const config = createConfig(["price"]) + const result = rewriteSelect(" * ", config) + + expect(result.select).toBe("*, price::text as __cent_price") + }) + }) + + describe("explicit column handling", () => { + it("casts money columns in explicit select", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, name, price", config) + + expect(result.select).toBe("id, name, price::text") + expect(result.tempColumns).toEqual([]) + }) + + it("casts multiple money columns", () => { + const config = createConfig(["price", "cost"]) + const result = rewriteSelect("id, price, name, cost", config) + + expect(result.select).toBe("id, price::text, name, cost::text") + }) + + it("leaves non-money columns unchanged", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, name, created_at", config) + + expect(result.select).toBe("id, name, created_at") + }) + + it("handles column aliases", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price as product_price", config) + + expect(result.select).toBe("id, price::text as product_price") + }) + + it("handles AS keyword (case insensitive)", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price AS product_price", config) + + expect(result.select).toBe("id, price::text AS product_price") + }) + }) + + describe("aggregate functions", () => { + it("casts sum() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price)", config) + + expect(result.select).toBe("sum(price)::text") + }) + + it("casts avg() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("avg(price)", config) + + expect(result.select).toBe("avg(price)::text") + }) + + it("casts min() and max() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("min(price), max(price)", config) + + expect(result.select).toBe("min(price)::text, max(price)::text") + }) + + it("handles aggregate with alias", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price) as total", config) + + expect(result.select).toBe("sum(price)::text as total") + }) + + it("does not cast count()", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("count(*), sum(price)", config) + + expect(result.select).toBe("count(*), sum(price)::text") + }) + }) + + describe("already-cast columns", () => { + it("does not double-cast columns with ::text", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price::text", config) + + expect(result.select).toBe("id, price::text") + }) + + it("does not modify columns with other casts", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price::numeric", config) + + expect(result.select).toBe("id, price::numeric") + }) + + it("does not modify already-cast aggregate", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price)::text", config) + + expect(result.select).toBe("sum(price)::text") + }) + }) + + describe("nested relations (limitations)", () => { + it("passes through nested relation selects unchanged", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, items(id, quantity)", config) + + // Nested relations are not rewritten (documented limitation) + expect(result.select).toBe("id, items(id, quantity)") + }) + + it("still casts top-level money columns with nested relations", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, items(id, quantity)", config) + + expect(result.select).toBe("id, price::text, items(id, quantity)") + }) + }) + + describe("edge cases", () => { + it("handles empty string", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("", config) + + expect(result.select).toBe("") + }) + + it("handles columns with underscores", () => { + const config = createConfig(["unit_price", "total_cost"]) + const result = rewriteSelect("id, unit_price, total_cost", config) + + expect(result.select).toBe("id, unit_price::text, total_cost::text") + }) + + it("handles mixed whitespace", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, name", config) + + expect(result.select).toBe("id, price::text, name") + }) + + it("handles columns that are substrings of others", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, price_history", config) + + // Should only cast 'price', not 'price_history' + expect(result.select).toBe("id, price::text, price_history") + }) + }) + }) + + describe("temp column utilities", () => { + describe("getTempColumnName", () => { + it("adds prefix to column name", () => { + expect(getTempColumnName("price")).toBe("__cent_price") + expect(getTempColumnName("total_cost")).toBe("__cent_total_cost") + }) + }) + + describe("isTempColumn", () => { + it("returns true for temp columns", () => { + expect(isTempColumn("__cent_price")).toBe(true) + expect(isTempColumn("__cent_total")).toBe(true) + }) + + it("returns false for regular columns", () => { + expect(isTempColumn("price")).toBe(false) + expect(isTempColumn("_price")).toBe(false) + expect(isTempColumn("cent_price")).toBe(false) + }) + }) + + describe("getOriginalColumnName", () => { + it("removes prefix from temp column", () => { + expect(getOriginalColumnName("__cent_price")).toBe("price") + expect(getOriginalColumnName("__cent_total_cost")).toBe("total_cost") + }) + + it("returns unchanged for non-temp columns", () => { + expect(getOriginalColumnName("price")).toBe("price") + expect(getOriginalColumnName("total")).toBe("total") + }) + }) + + describe("TEMP_COLUMN_PREFIX", () => { + it("is __cent_", () => { + expect(TEMP_COLUMN_PREFIX).toBe("__cent_") + }) + }) + }) +}) diff --git a/packages/cent-supabase/test/setup.ts b/packages/cent-supabase/test/setup.ts new file mode 100644 index 0000000..c4e090a --- /dev/null +++ b/packages/cent-supabase/test/setup.ts @@ -0,0 +1,8 @@ +// Test setup for @thesis-co/cent-supabase +// Add any global test configuration here + +// Ensure BigInt serialization works in Jest +expect.addSnapshotSerializer({ + test: (val) => typeof val === "bigint", + print: (val) => `${val}n`, +}) diff --git a/packages/cent-supabase/test/types.test.ts b/packages/cent-supabase/test/types.test.ts new file mode 100644 index 0000000..3f8d69b --- /dev/null +++ b/packages/cent-supabase/test/types.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@jest/globals" +import { + hasCurrencyCode, + hasCurrencyColumn, + normalizeConfig, + type CentSupabaseOptions, + type MoneyColumnConfig, +} from "../src/types" + +describe("types", () => { + describe("hasCurrencyColumn", () => { + it("returns true for dynamic currency config", () => { + const config: MoneyColumnConfig = { currencyColumn: "currency" } + expect(hasCurrencyColumn(config)).toBe(true) + }) + + it("returns false for static currency config", () => { + const config: MoneyColumnConfig = { currencyCode: "USD" } + expect(hasCurrencyColumn(config)).toBe(false) + }) + }) + + describe("hasCurrencyCode", () => { + it("returns true for static currency config", () => { + const config: MoneyColumnConfig = { currencyCode: "USD" } + expect(hasCurrencyCode(config)).toBe(true) + }) + + it("returns false for dynamic currency config", () => { + const config: MoneyColumnConfig = { currencyColumn: "currency" } + expect(hasCurrencyCode(config)).toBe(false) + }) + }) + + describe("normalizeConfig", () => { + it("normalizes a simple config", () => { + const options: CentSupabaseOptions = { + tables: { + orders: { + money: { + total: { currencyColumn: "currency" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.orders).toBeDefined() + expect(config.tables.orders.moneyColumns).toEqual(["total"]) + expect(config.tables.orders.money.total).toEqual({ + currencyColumn: "currency", + currencyCode: undefined, + minorUnits: false, + }) + }) + + it("handles static currency configs", () => { + const options: CentSupabaseOptions = { + tables: { + products: { + money: { + price: { currencyCode: "USD" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.products.money.price).toEqual({ + currencyColumn: undefined, + currencyCode: "USD", + minorUnits: false, + }) + }) + + it("handles minorUnits flag", () => { + const options: CentSupabaseOptions = { + tables: { + wallets: { + money: { + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.wallets.money.balance_sats.minorUnits).toBe(true) + }) + + it("handles multiple tables and columns", () => { + const options: CentSupabaseOptions = { + tables: { + orders: { + money: { + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }, + }, + products: { + money: { + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.orders.moneyColumns).toEqual(["total", "tax"]) + expect(config.tables.products.moneyColumns).toEqual(["price", "cost"]) + }) + }) +}) diff --git a/packages/cent-supabase/tsconfig.json b/packages/cent-supabase/tsconfig.json new file mode 100644 index 0000000..4d3324e --- /dev/null +++ b/packages/cent-supabase/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cent-zod/README.md b/packages/cent-zod/README.md new file mode 100644 index 0000000..b9c8457 --- /dev/null +++ b/packages/cent-zod/README.md @@ -0,0 +1,84 @@ +# @thesis-co/cent-zod + +Zod schemas for parsing and validating `@thesis-co/cent` types. + +```bash +pnpm add @thesis-co/cent-zod +``` + +## Schemas + +### zMoney + +```ts +import { zMoney, zMoneyString } from "@thesis-co/cent-zod" + +zMoneyString.parse("$100.50") // MoneyClass + +// With constraints +zMoney({ + currency: "USD", + min: "$0.50", + max: "$10000", + positive: true, +}) +``` + +### zPrice + +```ts +import { zPrice } from "@thesis-co/cent-zod" + +zPrice().parse({ numerator: "$50,000", denominator: "1 BTC" }) +zPrice().parse(["$50,000", "1 BTC"]) + +// Currency constraints +zPrice("USD", "BTC") +``` + +### zExchangeRate + +```ts +import { zExchangeRate } from "@thesis-co/cent-zod" + +zExchangeRate("USD", "EUR").parse({ base: "USD", quote: "EUR", rate: "0.92" }) + +// With staleness check +zExchangeRate({ base: "BTC", quote: "USD", maxAge: 60000 }) +``` + +### zPriceRange + +```ts +import { zPriceRange } from "@thesis-co/cent-zod" + +zPriceRange().parse("$50 - $100") +zPriceRange().parse({ min: "$50", max: "$100" }) + +// With constraints +zPriceRange({ + currency: "USD", + bounds: { min: "$0", max: "$10000" }, + minSpan: "$10", +}) +``` + +### zCurrency + +```ts +import { zCurrency } from "@thesis-co/cent-zod" + +zCurrency().parse("USD") // Currency object +zCurrency({ allowed: ["USD", "EUR", "GBP"] }) +zCurrency({ type: "crypto" }) +``` + +## Type Inference + +```ts +import { z } from "zod" +import { zMoney } from "@thesis-co/cent-zod" + +const schema = zMoney("USD") +type USDMoney = z.infer // MoneyClass +``` diff --git a/packages/cent-zod/jest.config.js b/packages/cent-zod/jest.config.js new file mode 100644 index 0000000..4e45349 --- /dev/null +++ b/packages/cent-zod/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-zod/package.json b/packages/cent-zod/package.json new file mode 100644 index 0000000..c673ebb --- /dev/null +++ b/packages/cent-zod/package.json @@ -0,0 +1,46 @@ +{ + "name": "@thesis-co/cent-zod", + "version": "0.0.1", + "description": "Zod schemas for validating and parsing @thesis-co/cent types", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thesis/cent.git", + "directory": "packages/cent-zod" + }, + "keywords": [ + "zod", + "validation", + "finance", + "currency", + "money", + "schema" + ], + "author": "Matt Luongo (@mhluongo)", + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "pnpx @biomejs/biome check", + "lint:fix": "pnpx @biomejs/biome check --write", + "build": "tsc", + "test": "jest", + "prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint" + }, + "devDependencies": { + "@thesis-co/cent": "workspace:*", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@thesis-co/cent": ">=0.0.5", + "zod": ">=4.0.0" + } +} diff --git a/packages/cent-zod/src/index.ts b/packages/cent-zod/src/index.ts new file mode 100644 index 0000000..dfef1d6 --- /dev/null +++ b/packages/cent-zod/src/index.ts @@ -0,0 +1,38 @@ +// Common schemas and utilities +export { + zBigIntString, + zDecimalString, + zFixedPointJSON, + zNonNegativeBigIntString, + zRationalNumberJSON, +} from "./schemas/common" +export type { ZCurrencyOptions } from "./schemas/currency" +// Currency schemas +export { + getValidCurrencyCodes, + zCurrency, + zCurrencyCode, + zCurrencyObject, +} from "./schemas/currency" +export type { ZExchangeRateOptions } from "./schemas/exchange-rate" +// Exchange rate schemas +export { + zExchangeRate, + zExchangeRateCompact, + zExchangeRateJSON, + zExchangeRateSource, +} from "./schemas/exchange-rate" +export type { ZMoneyOptions } from "./schemas/money" +// Money schemas +export { zMoney, zMoneyJSON, zMoneyString } from "./schemas/money" +export type { ZPriceOptions } from "./schemas/price" +// Price schemas +export { zPrice, zPriceFromObject, zPriceFromTuple } from "./schemas/price" +export type { ZPriceRangeOptions } from "./schemas/price-range" +// Price range schemas +export { + zPriceRange, + zPriceRangeJSON, + zPriceRangeObject, + zPriceRangeString, +} from "./schemas/price-range" diff --git a/packages/cent-zod/src/schemas/common.ts b/packages/cent-zod/src/schemas/common.ts new file mode 100644 index 0000000..389a86a --- /dev/null +++ b/packages/cent-zod/src/schemas/common.ts @@ -0,0 +1,42 @@ +import { z } from "zod" + +/** + * Schema for bigint values serialized as strings + */ +export const zBigIntString = z + .string() + .regex(/^-?\d+$/, "Must be a valid integer string") + .transform((val) => BigInt(val)) + +/** + * Schema for non-negative bigint values + */ +export const zNonNegativeBigIntString = z + .string() + .regex(/^\d+$/, "Must be a valid non-negative integer string") + .transform((val) => BigInt(val)) + +/** + * Schema for FixedPoint JSON representation + * Transforms to { amount: bigint, decimals: bigint } + */ +export const zFixedPointJSON = z.object({ + amount: zBigIntString, + decimals: zNonNegativeBigIntString, +}) + +/** + * Schema for RationalNumber JSON representation + * Validates { p: string, q: string } (no transform - Money.fromJSON handles conversion) + */ +export const zRationalNumberJSON = z.object({ + p: z.string().regex(/^-?\d+$/, "Must be a valid integer string"), + q: z.string().regex(/^-?\d+$/, "Must be a valid integer string"), +}) + +/** + * Schema for decimal string input (e.g., "123.45") + */ +export const zDecimalString = z + .string() + .regex(/^-?\d+(\.\d+)?$/, "Must be a valid decimal string") diff --git a/packages/cent-zod/src/schemas/currency.ts b/packages/cent-zod/src/schemas/currency.ts new file mode 100644 index 0000000..a44a726 --- /dev/null +++ b/packages/cent-zod/src/schemas/currency.ts @@ -0,0 +1,128 @@ +import { type Currency, currencies, getCurrencyFromCode } from "@thesis-co/cent" +import { z } from "zod" +import { zNonNegativeBigIntString } from "./common" + +/** + * Schema that validates a currency code string and transforms to Currency object + */ +export const zCurrencyCode = z.string().transform((code, ctx) => { + try { + return getCurrencyFromCode(code) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Unknown currency code: ${code}`, + }) + return z.NEVER + } +}) + +/** + * Schema for full Currency object representation (validation only, no transform) + */ +export const zCurrencyObject = z.object({ + name: z.string(), + code: z.string(), + decimals: z.union([z.bigint(), zNonNegativeBigIntString]), + symbol: z.string(), + fractionalUnit: z + .union([ + z.string(), + z.array(z.string()), + z.record(z.string(), z.union([z.string(), z.array(z.string())])), + ]) + .optional(), + iso4217Support: z.boolean().optional(), +}) + +/** + * Options for zCurrency schema + */ +export interface ZCurrencyOptions { + /** Only allow these currency codes */ + allowed?: string[] + /** Deny these currency codes */ + denied?: string[] + /** Filter by currency type */ + type?: "fiat" | "crypto" | "all" +} + +/** + * Create a currency validation schema + * + * @example + * ```ts + * // Any valid currency + * const schema = zCurrency() + * schema.parse("USD") // Returns USD Currency object + * + * // Only specific currencies + * const usdEurSchema = zCurrency({ allowed: ["USD", "EUR"] }) + * + * // Only fiat currencies + * const fiatSchema = zCurrency({ type: "fiat" }) + * ``` + */ +export function zCurrency(options?: ZCurrencyOptions) { + return z.string().transform((code, ctx) => { + const upperCode = code.toUpperCase() + + // Check allowlist + if (options?.allowed && !options.allowed.includes(upperCode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not in allowed list: ${options.allowed.join(", ")}`, + }) + return z.NEVER + } + + // Check denylist + if (options?.denied?.includes(upperCode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not allowed`, + }) + return z.NEVER + } + + // Get the currency + let currency: Currency + try { + currency = getCurrencyFromCode(upperCode) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Unknown currency code: ${code}`, + }) + return z.NEVER + } + + // Check type filter + if (options?.type && options.type !== "all") { + const isFiat = currency.iso4217Support === true + if (options.type === "fiat" && !isFiat) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not a fiat currency`, + }) + return z.NEVER + } + if (options.type === "crypto" && isFiat) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Currency ${upperCode} is not a cryptocurrency`, + }) + return z.NEVER + } + } + + return currency + }) +} + +/** + * Get all valid currency codes + */ +export function getValidCurrencyCodes(): string[] { + return Object.keys(currencies) +} diff --git a/packages/cent-zod/src/schemas/exchange-rate.ts b/packages/cent-zod/src/schemas/exchange-rate.ts new file mode 100644 index 0000000..4c70468 --- /dev/null +++ b/packages/cent-zod/src/schemas/exchange-rate.ts @@ -0,0 +1,177 @@ +import { + ExchangeRate, + FixedPointNumber, + getCurrencyFromCode, +} from "@thesis-co/cent" +import { z } from "zod" +import { zDecimalString, zFixedPointJSON } from "./common" +import { zCurrencyObject } from "./currency" + +/** + * Schema for ExchangeRateSource + */ +export const zExchangeRateSource = z.object({ + name: z.string(), + priority: z.number(), + reliability: z.number().min(0).max(1), +}) + +/** + * Schema for ExchangeRate from full JSON representation + */ +export const zExchangeRateJSON = z + .object({ + baseCurrency: z.union([zCurrencyObject, z.string()]), + quoteCurrency: z.union([zCurrencyObject, z.string()]), + rate: z.union([zFixedPointJSON, zDecimalString]), + timestamp: z.string().optional(), + source: zExchangeRateSource.optional(), + }) + .transform((data, ctx) => { + try { + const baseCurrency = + typeof data.baseCurrency === "string" + ? getCurrencyFromCode(data.baseCurrency) + : data.baseCurrency + const quoteCurrency = + typeof data.quoteCurrency === "string" + ? getCurrencyFromCode(data.quoteCurrency) + : data.quoteCurrency + + let rate: FixedPointNumber + if (typeof data.rate === "string") { + rate = FixedPointNumber.fromDecimalString(data.rate) + } else { + rate = new FixedPointNumber(data.rate.amount, data.rate.decimals) + } + + return new ExchangeRate({ + baseCurrency, + quoteCurrency, + rate, + timestamp: data.timestamp, + source: data.source, + }) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid exchange rate: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for compact ExchangeRate input + * e.g., { base: "USD", quote: "EUR", rate: "0.92" } + */ +export const zExchangeRateCompact = z + .object({ + base: z.string(), + quote: z.string(), + rate: z.string(), + timestamp: z.string().optional(), + }) + .transform((data, ctx) => { + try { + const baseCurrency = getCurrencyFromCode(data.base) + const quoteCurrency = getCurrencyFromCode(data.quote) + const rate = FixedPointNumber.fromDecimalString(data.rate) + + return new ExchangeRate({ + baseCurrency, + quoteCurrency, + rate, + timestamp: data.timestamp, + }) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid exchange rate: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zExchangeRate schema + */ +export interface ZExchangeRateOptions { + /** Expected base currency */ + base?: string + /** Expected quote currency */ + quote?: string + /** Maximum age of timestamp in milliseconds */ + maxAge?: number +} + +/** + * Create an ExchangeRate validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currencies + * const schema = zExchangeRate() + * schema.parse({ base: "USD", quote: "EUR", rate: "0.92" }) + * + * // With currency constraints + * const usdEurSchema = zExchangeRate("USD", "EUR") + * + * // With staleness check + * const freshRateSchema = zExchangeRate({ + * base: "BTC", + * quote: "USD", + * maxAge: 60000, // 1 minute + * }) + * ``` + */ +export function zExchangeRate( + baseOrOptions?: string | ZExchangeRateOptions, + quote?: string, +) { + const options: ZExchangeRateOptions = + typeof baseOrOptions === "string" + ? { base: baseOrOptions, quote } + : (baseOrOptions ?? {}) + + const baseSchema = z.union([zExchangeRateJSON, zExchangeRateCompact]) + + if (!options.base && !options.quote && !options.maxAge) { + return baseSchema + } + + return baseSchema.transform((rate, ctx) => { + // Validate base currency + if (options.base && rate.baseCurrency.code !== options.base) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected base currency ${options.base}, got ${rate.baseCurrency.code}`, + }) + return z.NEVER + } + + // Validate quote currency + if (options.quote && rate.quoteCurrency.code !== options.quote) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected quote currency ${options.quote}, got ${rate.quoteCurrency.code}`, + }) + return z.NEVER + } + + // Validate timestamp age + if (options.maxAge && rate.timestamp) { + const timestampMs = Number(rate.timestamp) * 1000 + const age = Date.now() - timestampMs + if (age > options.maxAge) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Exchange rate is stale: ${age}ms old (max: ${options.maxAge}ms)`, + }) + return z.NEVER + } + } + + return rate + }) +} diff --git a/packages/cent-zod/src/schemas/money.ts b/packages/cent-zod/src/schemas/money.ts new file mode 100644 index 0000000..6e337c8 --- /dev/null +++ b/packages/cent-zod/src/schemas/money.ts @@ -0,0 +1,183 @@ +import { + type Currency, + getCurrencyFromCode, + Money, + MoneyClass, +} from "@thesis-co/cent" +import { z } from "zod" +import { zDecimalString, zRationalNumberJSON } from "./common" +import { zCurrencyObject } from "./currency" + +/** + * Schema for MoneyAmount JSON: + * - Decimal string: "100.50" + * - Rational number: { p: "10050", q: "100" } + */ +const zMoneyAmountJSON = z.union([zDecimalString, zRationalNumberJSON]) + +/** + * Schema for Money JSON object representation + * Transforms to Money instance + */ +export const zMoneyJSON = z + .object({ + currency: z.union([zCurrencyObject, z.string()]), + amount: zMoneyAmountJSON, + }) + .transform((data) => { + return MoneyClass.fromJSON(data) + }) + +/** + * Schema that parses money string format (e.g., "$100.50", "100 USD") + * Transforms to Money instance + */ +export const zMoneyString = z.string().transform((val, ctx) => { + try { + return Money(val) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid money string: ${error instanceof Error ? error.message : "Unknown error"}`, + params: { centError: "PARSE_ERROR" }, + }) + return z.NEVER + } +}) + +/** + * Options for zMoney schema + */ +export interface ZMoneyOptions { + /** Restrict to specific currency */ + currency?: string + /** Minimum value (inclusive) */ + min?: string + /** Maximum value (inclusive) */ + max?: string + /** Require positive value (> 0) */ + positive?: boolean + /** Require non-negative value (>= 0) */ + nonNegative?: boolean + /** Require non-zero value (!= 0) */ + nonZero?: boolean +} + +/** + * Create a Money validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currency + * const schema = zMoney() + * schema.parse("$100.50") // Money instance + * + * // Currency constrained + * const usdSchema = zMoney("USD") + * usdSchema.parse("$100") // OK + * usdSchema.parse("€100") // Error + * + * // With validation options + * const paymentSchema = zMoney({ + * currency: "USD", + * min: "$0.50", + * max: "$10000", + * positive: true, + * }) + * ``` + */ +export function zMoney(currencyOrOptions?: string | ZMoneyOptions) { + const options: ZMoneyOptions = + typeof currencyOrOptions === "string" + ? { currency: currencyOrOptions } + : (currencyOrOptions ?? {}) + + // Parse min/max as Money if provided + let minMoney: InstanceType | undefined + let maxMoney: InstanceType | undefined + + if (options.min) { + try { + minMoney = Money(options.min) + } catch { + throw new Error(`Invalid min value: ${options.min}`) + } + } + + if (options.max) { + try { + maxMoney = Money(options.max) + } catch { + throw new Error(`Invalid max value: ${options.max}`) + } + } + + // Get expected currency if specified + let expectedCurrency: Currency | undefined + if (options.currency) { + expectedCurrency = getCurrencyFromCode(options.currency) + } + + return z + .union([zMoneyString, zMoneyJSON, z.instanceof(MoneyClass)]) + .transform((money, ctx) => { + // Handle passthrough of Money instances + const result = money instanceof MoneyClass ? money : money + + // Currency validation + if (expectedCurrency && result.currency.code !== expectedCurrency.code) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected currency ${expectedCurrency.code}, got ${result.currency.code}`, + }) + return z.NEVER + } + + // Min validation + if (minMoney && result.compare(minMoney) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Amount must be at least ${minMoney.toString()}`, + }) + return z.NEVER + } + + // Max validation + if (maxMoney && result.compare(maxMoney) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Amount must be at most ${maxMoney.toString()}`, + }) + return z.NEVER + } + + // Positive validation + if (options.positive && !result.isPositive()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must be positive", + }) + return z.NEVER + } + + // Non-negative validation + if (options.nonNegative && result.isNegative()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must be non-negative", + }) + return z.NEVER + } + + // Non-zero validation + if (options.nonZero && result.isZero()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: "Amount must not be zero", + }) + return z.NEVER + } + + return result + }) +} diff --git a/packages/cent-zod/src/schemas/price-range.ts b/packages/cent-zod/src/schemas/price-range.ts new file mode 100644 index 0000000..ae49887 --- /dev/null +++ b/packages/cent-zod/src/schemas/price-range.ts @@ -0,0 +1,193 @@ +import { + Money, + MoneyClass, + PriceRangeClass, + PriceRangeFactory, +} from "@thesis-co/cent" +import { z } from "zod" + +/** + * Schema for PriceRange from string (e.g., "$50 - $100") + */ +export const zPriceRangeString = z.string().transform((val, ctx) => { + try { + return PriceRangeFactory(val) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range string: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } +}) + +/** + * Schema for PriceRange from min/max object + */ +export const zPriceRangeObject = z + .object({ + min: z.union([z.string(), z.instanceof(MoneyClass)]), + max: z.union([z.string(), z.instanceof(MoneyClass)]), + }) + .transform((data, ctx) => { + try { + const min = typeof data.min === "string" ? Money(data.min) : data.min + const max = typeof data.max === "string" ? Money(data.max) : data.max + return new PriceRangeClass(min, max) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for PriceRange from JSON (with nested Money JSON) + */ +export const zPriceRangeJSON = z + .object({ + min: z.unknown(), + max: z.unknown(), + }) + .transform((data, ctx) => { + try { + return PriceRangeClass.fromJSON(data) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price range JSON: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zPriceRange schema + */ +export interface ZPriceRangeOptions { + /** Expected currency */ + currency?: string + /** Minimum allowed span between min and max */ + minSpan?: string + /** Maximum allowed span between min and max */ + maxSpan?: string + /** Bounds constraints */ + bounds?: { + /** Minimum allowed value for the range's min */ + min?: string + /** Maximum allowed value for the range's max */ + max?: string + } +} + +/** + * Create a PriceRange validation schema with optional constraints + * + * @example + * ```ts + * // Basic - any currency + * const schema = zPriceRange() + * schema.parse("$50 - $100") // PriceRange instance + * + * // Currency constrained + * const usdSchema = zPriceRange("USD") + * + * // With span and bounds constraints + * const filterSchema = zPriceRange({ + * currency: "USD", + * bounds: { min: "$0", max: "$10000" }, + * minSpan: "$10", + * }) + * ``` + */ +export function zPriceRange(currencyOrOptions?: string | ZPriceRangeOptions) { + const options: ZPriceRangeOptions = + typeof currencyOrOptions === "string" + ? { currency: currencyOrOptions } + : (currencyOrOptions ?? {}) + + const baseSchema = z.union([ + zPriceRangeString, + zPriceRangeObject, + zPriceRangeJSON, + ]) + + if ( + !options.currency && + !options.minSpan && + !options.maxSpan && + !options.bounds + ) { + return baseSchema + } + + // Parse constraint values + let minSpanMoney: InstanceType | undefined + let maxSpanMoney: InstanceType | undefined + let boundsMin: InstanceType | undefined + let boundsMax: InstanceType | undefined + + if (options.minSpan) { + minSpanMoney = Money(options.minSpan) + } + if (options.maxSpan) { + maxSpanMoney = Money(options.maxSpan) + } + if (options.bounds?.min) { + boundsMin = Money(options.bounds.min) + } + if (options.bounds?.max) { + boundsMax = Money(options.bounds.max) + } + + return baseSchema.transform((range, ctx) => { + // Currency validation + if (options.currency && range.currency.code !== options.currency) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected currency ${options.currency}, got ${range.currency.code}`, + }) + return z.NEVER + } + + // Min span validation + if (minSpanMoney && range.span.compare(minSpanMoney) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range span must be at least ${minSpanMoney.toString()}`, + }) + return z.NEVER + } + + // Max span validation + if (maxSpanMoney && range.span.compare(maxSpanMoney) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range span must be at most ${maxSpanMoney.toString()}`, + }) + return z.NEVER + } + + // Bounds min validation + if (boundsMin && range.min.compare(boundsMin) < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range minimum must be at least ${boundsMin.toString()}`, + }) + return z.NEVER + } + + // Bounds max validation + if (boundsMax && range.max.compare(boundsMax) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Range maximum must be at most ${boundsMax.toString()}`, + }) + return z.NEVER + } + + return range + }) +} diff --git a/packages/cent-zod/src/schemas/price.ts b/packages/cent-zod/src/schemas/price.ts new file mode 100644 index 0000000..d9b235a --- /dev/null +++ b/packages/cent-zod/src/schemas/price.ts @@ -0,0 +1,118 @@ +import { Money, MoneyClass, Price } from "@thesis-co/cent" +import { z } from "zod" + +/** + * Schema for Price from an object with two amounts + */ +export const zPriceFromObject = z + .object({ + numerator: z.union([z.string(), z.instanceof(MoneyClass)]), + denominator: z.union([z.string(), z.instanceof(MoneyClass)]), + time: z.string().optional(), + }) + .transform((data, ctx) => { + try { + const numerator = + typeof data.numerator === "string" + ? Money(data.numerator) + : data.numerator + const denominator = + typeof data.denominator === "string" + ? Money(data.denominator) + : data.denominator + return new Price(numerator, denominator, data.time) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Schema for Price from a tuple of two money strings + * e.g., ["$50,000", "1 BTC"] + */ +export const zPriceFromTuple = z + .tuple([z.string(), z.string()]) + .transform((data, ctx) => { + try { + const [str1, str2] = data + const money1 = Money(str1) + const money2 = Money(str2) + return new Price(money1, money2) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Invalid price: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + return z.NEVER + } + }) + +/** + * Options for zPrice schema + */ +export interface ZPriceOptions { + /** Expected currency for numerator */ + numeratorCurrency?: string + /** Expected currency for denominator */ + denominatorCurrency?: string +} + +/** + * Create a Price validation schema with optional currency constraints + * + * @example + * ```ts + * // Basic - any currencies + * const schema = zPrice() + * schema.parse({ numerator: "$50,000", denominator: "1 BTC" }) + * + * // With currency constraints + * const btcUsdSchema = zPrice("USD", "BTC") + * btcUsdSchema.parse(["$50,000", "1 BTC"]) // OK + * ``` + */ +export function zPrice( + numeratorCurrency?: string, + denominatorCurrency?: string, +) { + const baseSchema = z.union([zPriceFromObject, zPriceFromTuple]) + + if (!numeratorCurrency && !denominatorCurrency) { + return baseSchema + } + + return baseSchema.transform((price, ctx) => { + const numCurrency = price.amounts[0].asset + const denomCurrency = price.amounts[1].asset + + if ( + numeratorCurrency && + "code" in numCurrency && + numCurrency.code !== numeratorCurrency + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected numerator currency ${numeratorCurrency}, got ${numCurrency.code}`, + }) + return z.NEVER + } + + if ( + denominatorCurrency && + "code" in denomCurrency && + denomCurrency.code !== denominatorCurrency + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + error: `Expected denominator currency ${denominatorCurrency}, got ${denomCurrency.code}`, + }) + return z.NEVER + } + + return price + }) +} diff --git a/packages/cent-zod/test/currency.test.ts b/packages/cent-zod/test/currency.test.ts new file mode 100644 index 0000000..4197bbe --- /dev/null +++ b/packages/cent-zod/test/currency.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "@jest/globals" +import { getValidCurrencyCodes, zCurrency, zCurrencyCode } from "../src" + +describe("zCurrencyCode", () => { + it("transforms valid currency code to Currency object", () => { + const result = zCurrencyCode.parse("USD") + expect(result.code).toBe("USD") + expect(result.symbol).toBe("$") + }) + + it("handles lowercase input", () => { + const result = zCurrencyCode.parse("usd") + expect(result.code).toBe("USD") + }) + + it("rejects unknown currency codes", () => { + expect(() => zCurrencyCode.parse("XXX")).toThrow(/Unknown currency/) + }) +}) + +describe("zCurrency", () => { + it("accepts any valid currency by default", () => { + const schema = zCurrency() + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + expect(schema.parse("BTC").code).toBe("BTC") + }) + + describe("with allowed list", () => { + it("accepts currencies in allowed list", () => { + const schema = zCurrency({ allowed: ["USD", "EUR"] }) + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + }) + + it("rejects currencies not in allowed list", () => { + const schema = zCurrency({ allowed: ["USD", "EUR"] }) + expect(() => schema.parse("GBP")).toThrow(/not in allowed list/) + }) + }) + + describe("with denied list", () => { + it("rejects currencies in denied list", () => { + const schema = zCurrency({ denied: ["BTC"] }) + expect(() => schema.parse("BTC")).toThrow(/not allowed/) + }) + + it("accepts currencies not in denied list", () => { + const schema = zCurrency({ denied: ["BTC"] }) + expect(schema.parse("USD").code).toBe("USD") + }) + }) + + describe("with type filter", () => { + it("fiat filter accepts fiat currencies", () => { + const schema = zCurrency({ type: "fiat" }) + expect(schema.parse("USD").code).toBe("USD") + expect(schema.parse("EUR").code).toBe("EUR") + }) + + it("fiat filter rejects crypto currencies", () => { + const schema = zCurrency({ type: "fiat" }) + expect(() => schema.parse("BTC")).toThrow(/not a fiat currency/) + }) + + it("crypto filter accepts crypto currencies", () => { + const schema = zCurrency({ type: "crypto" }) + expect(schema.parse("BTC").code).toBe("BTC") + expect(schema.parse("ETH").code).toBe("ETH") + }) + + it("crypto filter rejects fiat currencies", () => { + const schema = zCurrency({ type: "crypto" }) + expect(() => schema.parse("USD")).toThrow(/not a cryptocurrency/) + }) + }) +}) + +describe("getValidCurrencyCodes", () => { + it("returns array of currency codes", () => { + const codes = getValidCurrencyCodes() + expect(codes).toContain("USD") + expect(codes).toContain("EUR") + expect(codes).toContain("BTC") + expect(codes.length).toBeGreaterThan(100) + }) +}) diff --git a/packages/cent-zod/test/exchange-rate.test.ts b/packages/cent-zod/test/exchange-rate.test.ts new file mode 100644 index 0000000..87b2894 --- /dev/null +++ b/packages/cent-zod/test/exchange-rate.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "@jest/globals" +import { ExchangeRate } from "@thesis-co/cent" +import { zExchangeRate, zExchangeRateCompact, zExchangeRateJSON } from "../src" + +describe("zExchangeRateCompact", () => { + it("parses compact exchange rate format", () => { + const result = zExchangeRateCompact.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + expect(result.baseCurrency.code).toBe("USD") + expect(result.quoteCurrency.code).toBe("EUR") + }) + + it("accepts optional timestamp", () => { + const result = zExchangeRateCompact.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + timestamp: "1704067200", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) +}) + +describe("zExchangeRateJSON", () => { + it("parses full JSON format with currency codes", () => { + const result = zExchangeRateJSON.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + it("parses JSON format with fixed point rate", () => { + const result = zExchangeRateJSON.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: { amount: "92", decimals: "2" }, + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) +}) + +describe("zExchangeRate", () => { + it("accepts compact format", () => { + const schema = zExchangeRate() + const result = schema.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + it("accepts JSON format", () => { + const schema = zExchangeRate() + const result = schema.parse({ + baseCurrency: "USD", + quoteCurrency: "EUR", + rate: "0.92", + }) + expect(result).toBeInstanceOf(ExchangeRate) + }) + + describe("with currency constraints", () => { + it("accepts matching currency pair", () => { + const schema = zExchangeRate("USD", "EUR") + const result = schema.parse({ + base: "USD", + quote: "EUR", + rate: "0.92", + }) + expect(result.baseCurrency.code).toBe("USD") + expect(result.quoteCurrency.code).toBe("EUR") + }) + + it("rejects non-matching base currency", () => { + const schema = zExchangeRate("USD", "EUR") + expect(() => + schema.parse({ + base: "GBP", + quote: "EUR", + rate: "0.92", + }), + ).toThrow(/Expected base currency USD/) + }) + + it("rejects non-matching quote currency", () => { + const schema = zExchangeRate("USD", "EUR") + expect(() => + schema.parse({ + base: "USD", + quote: "GBP", + rate: "0.92", + }), + ).toThrow(/Expected quote currency EUR/) + }) + }) + + describe("with options object", () => { + it("accepts options with base and quote", () => { + const schema = zExchangeRate({ base: "BTC", quote: "USD" }) + const result = schema.parse({ + base: "BTC", + quote: "USD", + rate: "50000", + }) + expect(result.baseCurrency.code).toBe("BTC") + }) + }) +}) diff --git a/packages/cent-zod/test/money.test.ts b/packages/cent-zod/test/money.test.ts new file mode 100644 index 0000000..117acc0 --- /dev/null +++ b/packages/cent-zod/test/money.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@jest/globals" +import { MoneyClass } from "@thesis-co/cent" +import { zMoney, zMoneyJSON, zMoneyString } from "../src" + +describe("zMoneyString", () => { + it("parses money string with symbol prefix", () => { + const result = zMoneyString.parse("$100.50") + expect(result).toBeInstanceOf(MoneyClass) + expect(result.toString()).toBe("$100.50") + }) + + it("parses money string with currency code suffix", () => { + const result = zMoneyString.parse("100.50 USD") + expect(result).toBeInstanceOf(MoneyClass) + expect(result.currency.code).toBe("USD") + }) + + it("parses euro amounts", () => { + const result = zMoneyString.parse("€50.00") + expect(result.currency.code).toBe("EUR") + }) + + it("rejects invalid money strings", () => { + expect(() => zMoneyString.parse("invalid")).toThrow() + }) +}) + +describe("zMoneyJSON", () => { + it("parses Money JSON with currency code string", () => { + const result = zMoneyJSON.parse({ + currency: "USD", + amount: "100.50", + }) + expect(result).toBeInstanceOf(MoneyClass) + expect(result.toString()).toBe("$100.50") + }) + + it("parses Money JSON with full currency object", () => { + const result = zMoneyJSON.parse({ + currency: { + name: "United States dollar", + code: "USD", + decimals: "2", + symbol: "$", + }, + amount: "50.00", + }) + expect(result).toBeInstanceOf(MoneyClass) + // Custom currency objects preserve their properties + expect(result.currency.name).toBe("United States dollar") + }) + + it("parses Money JSON with rational amount", () => { + const result = zMoneyJSON.parse({ + currency: "USD", + amount: { p: "10050", q: "100" }, + }) + expect(result).toBeInstanceOf(MoneyClass) + }) +}) + +describe("zMoney", () => { + it("accepts string format", () => { + const schema = zMoney() + const result = schema.parse("$100.50") + expect(result).toBeInstanceOf(MoneyClass) + }) + + it("accepts JSON format", () => { + const schema = zMoney() + const result = schema.parse({ + currency: "USD", + amount: "100.50", + }) + expect(result).toBeInstanceOf(MoneyClass) + }) + + it("accepts Money instance passthrough", () => { + const schema = zMoney() + const money = MoneyClass.fromJSON({ + currency: "USD", + amount: "100.50", + }) + const result = schema.parse(money) + expect(result).toBe(money) + }) + + describe("with currency constraint", () => { + it("accepts matching currency", () => { + const schema = zMoney("USD") + const result = schema.parse("$100.00") + expect(result.currency.code).toBe("USD") + }) + + it("rejects non-matching currency", () => { + const schema = zMoney("USD") + expect(() => schema.parse("€100.00")).toThrow(/Expected currency USD/) + }) + }) + + describe("with min/max constraints", () => { + it("accepts value within range", () => { + const schema = zMoney({ min: "$10.00", max: "$100.00" }) + const result = schema.parse("$50.00") + expect(result.toString()).toBe("$50.00") + }) + + it("rejects value below min", () => { + const schema = zMoney({ min: "$10.00" }) + expect(() => schema.parse("$5.00")).toThrow(/at least/) + }) + + it("rejects value above max", () => { + const schema = zMoney({ max: "$100.00" }) + expect(() => schema.parse("$150.00")).toThrow(/at most/) + }) + }) + + describe("with positive/nonNegative/nonZero constraints", () => { + it("positive rejects zero", () => { + const schema = zMoney({ positive: true }) + expect(() => schema.parse("$0.00")).toThrow(/positive/) + }) + + it("positive rejects negative", () => { + const schema = zMoney({ positive: true }) + expect(() => schema.parse("-$10.00")).toThrow(/positive/) + }) + + it("nonNegative accepts zero", () => { + const schema = zMoney({ nonNegative: true }) + const result = schema.parse("$0.00") + expect(result.isZero()).toBe(true) + }) + + it("nonNegative rejects negative", () => { + const schema = zMoney({ nonNegative: true }) + expect(() => schema.parse("-$10.00")).toThrow(/non-negative/) + }) + + it("nonZero rejects zero", () => { + const schema = zMoney({ nonZero: true }) + expect(() => schema.parse("$0.00")).toThrow(/not be zero/) + }) + }) +}) diff --git a/packages/cent-zod/test/price-range.test.ts b/packages/cent-zod/test/price-range.test.ts new file mode 100644 index 0000000..5189020 --- /dev/null +++ b/packages/cent-zod/test/price-range.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@jest/globals" +import { PriceRangeClass } from "@thesis-co/cent" +import { zPriceRange, zPriceRangeObject, zPriceRangeString } from "../src" + +describe("zPriceRangeString", () => { + it("parses price range string with dash separator", () => { + const result = zPriceRangeString.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + expect(result.min.toString()).toBe("$50.00") + expect(result.max.toString()).toBe("$100.00") + }) + + it("parses price range string without spaces", () => { + const result = zPriceRangeString.parse("$50-$100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("rejects invalid range strings", () => { + expect(() => zPriceRangeString.parse("invalid")).toThrow() + }) +}) + +describe("zPriceRangeObject", () => { + it("parses object with min and max strings", () => { + const result = zPriceRangeObject.parse({ + min: "$50.00", + max: "$100.00", + }) + expect(result).toBeInstanceOf(PriceRangeClass) + expect(result.min.toString()).toBe("$50.00") + expect(result.max.toString()).toBe("$100.00") + }) +}) + +describe("zPriceRange", () => { + it("accepts string format", () => { + const schema = zPriceRange() + const result = schema.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("accepts object format", () => { + const schema = zPriceRange() + const result = schema.parse({ + min: "$50.00", + max: "$100.00", + }) + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + describe("with currency constraint", () => { + it("accepts matching currency", () => { + const schema = zPriceRange("USD") + const result = schema.parse("$50 - $100") + expect(result.currency.code).toBe("USD") + }) + + it("rejects non-matching currency", () => { + const schema = zPriceRange("USD") + expect(() => schema.parse("€50 - €100")).toThrow(/Expected currency USD/) + }) + }) + + describe("with span constraints", () => { + it("accepts range with sufficient span", () => { + const schema = zPriceRange({ minSpan: "$10.00" }) + const result = schema.parse("$50 - $100") + expect(result.span.toString()).toBe("$50.00") + }) + + it("rejects range with insufficient span", () => { + const schema = zPriceRange({ minSpan: "$100.00" }) + expect(() => schema.parse("$50 - $60")).toThrow(/span must be at least/) + }) + + it("rejects range with excessive span", () => { + const schema = zPriceRange({ maxSpan: "$20.00" }) + expect(() => schema.parse("$50 - $100")).toThrow(/span must be at most/) + }) + }) + + describe("with bounds constraints", () => { + it("accepts range within bounds", () => { + const schema = zPriceRange({ + bounds: { min: "$0.00", max: "$1000.00" }, + }) + const result = schema.parse("$50 - $100") + expect(result).toBeInstanceOf(PriceRangeClass) + }) + + it("rejects range below bounds min", () => { + const schema = zPriceRange({ + bounds: { min: "$100.00" }, + }) + expect(() => schema.parse("$50 - $200")).toThrow( + /Range minimum must be at least/, + ) + }) + + it("rejects range above bounds max", () => { + const schema = zPriceRange({ + bounds: { max: "$100.00" }, + }) + expect(() => schema.parse("$50 - $200")).toThrow( + /Range maximum must be at most/, + ) + }) + }) +}) diff --git a/packages/cent-zod/test/price.test.ts b/packages/cent-zod/test/price.test.ts new file mode 100644 index 0000000..18e5455 --- /dev/null +++ b/packages/cent-zod/test/price.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@jest/globals" +import { Price } from "@thesis-co/cent" +import { zPrice, zPriceFromObject, zPriceFromTuple } from "../src" + +describe("zPriceFromTuple", () => { + it("parses tuple of money strings", () => { + const result = zPriceFromTuple.parse(["$50000", "1 BTC"]) + expect(result).toBeInstanceOf(Price) + expect(result.amounts[0].asset.code).toBe("USD") + expect(result.amounts[1].asset.code).toBe("BTC") + }) +}) + +describe("zPriceFromObject", () => { + it("parses object with numerator and denominator strings", () => { + const result = zPriceFromObject.parse({ + numerator: "$50000", + denominator: "1 BTC", + }) + expect(result).toBeInstanceOf(Price) + }) + + it("parses object with optional time", () => { + const result = zPriceFromObject.parse({ + numerator: "$50000", + denominator: "1 BTC", + time: "1704067200", + }) + expect(result).toBeInstanceOf(Price) + }) +}) + +describe("zPrice", () => { + it("accepts tuple format", () => { + const schema = zPrice() + const result = schema.parse(["$50000", "1 BTC"]) + expect(result).toBeInstanceOf(Price) + }) + + it("accepts object format", () => { + const schema = zPrice() + const result = schema.parse({ + numerator: "$50000", + denominator: "1 BTC", + }) + expect(result).toBeInstanceOf(Price) + }) + + describe("with currency constraints", () => { + it("accepts matching currencies", () => { + const schema = zPrice("USD", "BTC") + const result = schema.parse(["$50000", "1 BTC"]) + expect(result.amounts[0].asset.code).toBe("USD") + expect(result.amounts[1].asset.code).toBe("BTC") + }) + + it("rejects non-matching numerator currency", () => { + const schema = zPrice("USD", "BTC") + expect(() => schema.parse(["€50000", "1 BTC"])).toThrow( + /Expected numerator currency USD/, + ) + }) + + it("rejects non-matching denominator currency", () => { + const schema = zPrice("USD", "BTC") + expect(() => schema.parse(["$50000", "1 ETH"])).toThrow( + /Expected denominator currency BTC/, + ) + }) + }) +}) diff --git a/packages/cent-zod/tsconfig.json b/packages/cent-zod/tsconfig.json new file mode 100644 index 0000000..8c0e217 --- /dev/null +++ b/packages/cent-zod/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cent/package.json b/packages/cent/package.json index ff403e4..f408b78 100644 --- a/packages/cent/package.json +++ b/packages/cent/package.json @@ -38,7 +38,7 @@ "ts-jest": "^29.1.2" }, "dependencies": { - "zod": "^3.25.67" + "zod": "^4.0.0" }, "browserslist": { "production": [ diff --git a/packages/cent/src/money/index.ts b/packages/cent/src/money/index.ts index 0a19016..173a742 100644 --- a/packages/cent/src/money/index.ts +++ b/packages/cent/src/money/index.ts @@ -1551,6 +1551,63 @@ export class Money { return MoneyJSONSchema.parse(result) } + /** + * Get the amount as a decimal string (e.g., "100.50"). + * Useful for database storage in DECIMAL/NUMERIC columns. + * + * @returns The amount as a decimal string without currency symbol + * + * @example + * const price = Money("$100.50"); + * price.toDecimalString(); // "100.50" + * + * const btc = Money("1.5 BTC"); + * btc.toDecimalString(); // "1.5" + */ + toDecimalString(): string { + if (isFixedPointNumber(this.amount)) { + return this.amount.toString() + } + return this.amount.toDecimalString(50n) + } + + /** + * Get the amount in minor units (cents, satoshis, wei, etc.) + * scaled to the currency's canonical decimal places. + * + * @returns The amount as a bigint in the smallest currency unit + * + * @example + * const price = Money("$100.50"); + * price.toMinorUnits(); // 10050n (cents) + * + * const btc = Money("1.5 BTC"); + * btc.toMinorUnits(); // 150000000n (satoshis) + */ + toMinorUnits(): bigint { + const currencyDecimals = BigInt(this.currency.decimals) + const fixedPoint = isFixedPointNumber(this.amount) + ? this.amount + : toFixedPointNumber(this.amount, currencyDecimals) + + const currentDecimals = fixedPoint.decimals + const currentAmount = fixedPoint.amount + + if (currentDecimals === currencyDecimals) { + return currentAmount + } + + if (currentDecimals > currencyDecimals) { + // Truncate extra precision + const scale = 10n ** (currentDecimals - currencyDecimals) + return currentAmount / scale + } + + // Scale up + const scale = 10n ** (currencyDecimals - currentDecimals) + return currentAmount * scale + } + /** * Compare this Money instance with another Money instance * diff --git a/packages/cent/test/money.test.ts b/packages/cent/test/money.test.ts index ad7975d..5fd35e7 100644 --- a/packages/cent/test/money.test.ts +++ b/packages/cent/test/money.test.ts @@ -2,11 +2,13 @@ import { findFractionalUnitInfo, getCurrencyDisplayPart, Money, + MoneyFactory, MoneyJSONSchema, normalizeLocale, pluralizeFractionalUnit, shouldUseIsoFormatting, } from "../src/money" +import { FixedPointNumber } from "../src/fixed-point" import { type AssetAmount, CEIL, @@ -2980,4 +2982,111 @@ describe("Money", () => { }) }) }) + + describe("toDecimalString", () => { + it("should return decimal string for USD amounts", () => { + const money = MoneyFactory("$100.50") + expect(money.toDecimalString()).toBe("100.50") + }) + + it("should return decimal string without currency symbol", () => { + const money = MoneyFactory("€1234.56") + expect(money.toDecimalString()).toBe("1234.56") + }) + + it("should handle zero amounts", () => { + const money = MoneyFactory("$0.00") + expect(money.toDecimalString()).toBe("0.00") + }) + + it("should handle whole numbers", () => { + const money = MoneyFactory("$100") + expect(money.toDecimalString()).toBe("100.00") + }) + + it("should handle very small decimals", () => { + const money = MoneyFactory("0.00000001 BTC") + expect(money.toDecimalString()).toBe("0.00000001") + }) + + it("should handle large numbers without precision loss", () => { + // 900719925474099.28 - larger than MAX_SAFE_INTEGER + const money = MoneyFactory("$900719925474099.28") + expect(money.toDecimalString()).toBe("900719925474099.28") + }) + + it("should handle negative amounts", () => { + const money = MoneyFactory("-$50.25") + expect(money.toDecimalString()).toBe("-50.25") + }) + }) + + describe("toMinorUnits", () => { + it("should return cents for USD amounts", () => { + const money = MoneyFactory("$100.50") + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should return cents for EUR amounts", () => { + const money = MoneyFactory("€50.25") + expect(money.toMinorUnits()).toBe(5025n) + }) + + it("should return satoshis for BTC amounts", () => { + const money = MoneyFactory("1.5 BTC") + expect(money.toMinorUnits()).toBe(150000000n) + }) + + it("should handle whole numbers", () => { + const money = MoneyFactory("$100") + expect(money.toMinorUnits()).toBe(10000n) + }) + + it("should handle zero amounts", () => { + const money = MoneyFactory("$0.00") + expect(money.toMinorUnits()).toBe(0n) + }) + + it("should handle amounts created from minor units", () => { + const money = MoneyFactory(10050n, "USD") + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should truncate extra precision to currency decimals", () => { + // USD has 2 decimals, so extra precision should be truncated + const money = new Money( + usdCurrency, + new FixedPointNumber(100505n, 3n), // 100.505 with 3 decimals + ) + // Should truncate to 10050 cents (100.50) + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should handle large numbers without precision loss", () => { + // Create money with a very large amount + const money = MoneyFactory("$900719925474099.28") + expect(money.toMinorUnits()).toBe(90071992547409928n) + }) + + it("should handle negative amounts", () => { + const money = MoneyFactory("-$50.25") + expect(money.toMinorUnits()).toBe(-5025n) + }) + + it("should handle sub-satoshi BTC amounts by truncating", () => { + // BTC has 8 decimals, 0.000000001 would be sub-satoshi + const btcCurrency: Currency = { + name: "Bitcoin", + code: "BTC", + decimals: 8n, + symbol: "₿", + } + const money = new Money( + btcCurrency, + new FixedPointNumber(15n, 10n), // 0.0000000015 BTC (sub-satoshi) + ) + // Should truncate to 0 satoshis + expect(money.toMinorUnits()).toBe(0n) + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3741b2f..137734b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: packages/cent: dependencies: zod: - specifier: ^3.25.67 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.5 devDependencies: '@types/jest': specifier: ^29.5.12 @@ -37,8 +37,98 @@ importers: specifier: ^29.1.2 version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + packages/cent-react: + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.2.0 + version: 14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.0 + version: 14.6.1(@testing-library/dom@9.3.4) + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + '@types/react': + specifier: ^18.2.0 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.27) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + + packages/cent-supabase: + devDependencies: + '@supabase/supabase-js': + specifier: ^2.49.1 + version: 2.90.1 + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + typescript: + specifier: ^5.5.4 + version: 5.5.4 + + packages/cent-zod: + devDependencies: + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + zod: + specifier: ^4.0.0 + version: 4.3.5 + packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -193,6 +283,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -357,11 +451,63 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@supabase/auth-js@2.90.1': + resolution: {integrity: sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.90.1': + resolution: {integrity: sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.90.1': + resolution: {integrity: sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.90.1': + resolution: {integrity: sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.90.1': + resolution: {integrity: sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.90.1': + resolution: {integrity: sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==} + engines: {node: '>=20.0.0'} + + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@14.3.1': + resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@thesis-co/biome-config@https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606': resolution: {tarball: https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606} version: 0.0.1 engines: {node: '>=14.0.0'} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -389,18 +535,61 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -424,9 +613,27 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -480,6 +687,18 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -528,6 +747,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -543,6 +766,26 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -552,6 +795,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.6.0: resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} peerDependencies: @@ -560,10 +806,26 @@ packages: babel-plugin-macros: optional: true + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -572,6 +834,21 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -587,9 +864,32 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -598,11 +898,24 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -632,6 +945,14 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -643,6 +964,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -651,10 +975,18 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -663,24 +995,63 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -690,6 +1061,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -697,13 +1072,41 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -712,14 +1115,56 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -794,6 +1239,15 @@ packages: resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -888,6 +1342,15 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -919,9 +1382,17 @@ packages: lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -932,6 +1403,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -939,10 +1414,22 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -970,6 +1457,25 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -997,6 +1503,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1027,6 +1536,14 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1035,16 +1552,49 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -1062,6 +1612,20 @@ packages: engines: {node: '>= 0.4'} hasBin: true + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1071,6 +1635,14 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1079,6 +1651,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1103,6 +1691,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -1123,6 +1715,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1139,6 +1735,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -1150,6 +1749,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + ts-jest@29.4.0: resolution: {integrity: sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -1177,6 +1784,9 @@ packages: jest-util: optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo-darwin-64@2.7.3: resolution: {integrity: sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w==} cpu: [x64] @@ -1231,19 +1841,59 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1260,6 +1910,25 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1279,11 +1948,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} snapshots: + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.12 @@ -1453,6 +2124,8 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1709,10 +2382,86 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@supabase/auth-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.90.1': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.90.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.90.1': + dependencies: + '@supabase/auth-js': 2.90.1 + '@supabase/functions-js': 2.90.1 + '@supabase/postgrest-js': 2.90.1 + '@supabase/realtime-js': 2.90.1 + '@supabase/storage-js': 2.90.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@testing-library/user-event@14.6.1(@testing-library/dom@9.3.4)': + dependencies: + '@testing-library/dom': 9.3.4 + '@thesis-co/biome-config@https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606': dependencies: '@biomejs/biome': 2.1.1 + '@tootallnate/once@2.0.0': {} + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -1753,18 +2502,62 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 20.19.9 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/node@20.19.9': dependencies: undici-types: 6.21.0 + '@types/phoenix@1.6.7': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/stack-utils@2.0.3': {} + '@types/tough-cookie@4.0.5': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.9 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': dependencies: '@types/yargs-parser': 21.0.3 + abab@2.0.6: {} + + acorn-globals@7.0.1: + dependencies: + acorn: 8.15.0 + acorn-walk: 8.3.4 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -1786,8 +2579,25 @@ snapshots: dependencies: sprintf-js: 1.0.3 + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + async@3.2.6: {} + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + babel-jest@29.7.0(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 @@ -1875,6 +2685,23 @@ snapshots: buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -1910,6 +2737,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -1935,18 +2766,87 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + debug@4.4.1: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.6.0: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + detect-newline@3.1.0: {} diff-sequences@29.6.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + ejs@3.1.10: dependencies: jake: 10.9.2 @@ -1957,16 +2857,57 @@ snapshots: emoji-regex@8.0.0: {} + entities@6.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + esprima@4.0.1: {} + estraverse@5.3.0: {} + + esutils@2.0.3: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -2008,6 +2949,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -2015,12 +2968,32 @@ snapshots: function-bind@1.1.2: {} + functions-have-names@1.2.3: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@6.0.1: {} glob@7.2.3: @@ -2032,18 +3005,57 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-escaper@2.0.2: {} + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} + iceberg-js@0.8.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -2051,6 +3063,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -2058,20 +3072,95 @@ snapshots: inherits@2.0.4: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} + is-map@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-weakmap@2.0.2: {} + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2222,6 +3311,21 @@ snapshots: jest-util: 29.7.0 pretty-format: 29.7.0 + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.19.9 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -2437,6 +3541,39 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.15.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.19.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -2455,10 +3592,16 @@ snapshots: lodash.memoize@4.1.2: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -2469,6 +3612,8 @@ snapshots: dependencies: tmpl: 1.0.5 + math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -2476,8 +3621,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2500,6 +3653,26 @@ snapshots: dependencies: path-key: 3.1.1 + nwsapi@2.2.23: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -2529,6 +3702,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -2547,6 +3724,14 @@ snapshots: dependencies: find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -2558,12 +3743,48 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + pure-rand@6.1.0: {} + querystringify@2.2.0: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + react-is@18.3.1: {} + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -2578,16 +3799,76 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@6.3.1: {} semver@7.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} sisteransi@1.0.5: {} @@ -2607,6 +3888,11 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -2626,6 +3912,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -2638,6 +3928,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -2650,6 +3942,17 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + ts-jest@29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4): dependencies: bs-logger: 0.2.6 @@ -2670,6 +3973,8 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.0) jest-util: 29.7.0 + tslib@2.8.1: {} + turbo-darwin-64@2.7.3: optional: true @@ -2707,22 +4012,71 @@ snapshots: undici-types@6.21.0: {} + universalify@0.2.0: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 escalade: 3.2.0 picocolors: 1.1.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.29 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2740,6 +4094,12 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.19.0: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -2758,4 +4118,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.76: {} + zod@4.3.5: {}