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 && (
+ Rate may be outdated. Refresh?
+ )}
+
+ );
+}
+```
+
+**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: {}