@arraypress/price-utils
v1.1.0
Published
Price display utilities for e-commerce — recurring interval labels, discount percentage calculation, compare-at price detection.
Maintainers
Readme
@arraypress/price-utils
Price display utilities for e-commerce -- recurring interval labels, discount percentage calculation, compare-at price detection. Works with any price object shape (camelCase or snake_case fields).
Zero dependencies. Works in Node.js, Cloudflare Workers, Deno, Bun, and browsers.
Installation
npm install @arraypress/price-utilsUsage
import {
isRecurring,
getIntervalLabel,
hasCompareAt,
getDiscountPercent,
getSavingsAmount,
getPriceTypeLabel,
isFree,
} from '@arraypress/price-utils';
// Recurring price detection
isRecurring({ type: 'recurring', recurringInterval: 'month' }); // true
isRecurring({ type: 'one_time' }); // false
// Human-readable interval labels
getIntervalLabel({ recurringInterval: 'month' }); // 'Monthly'
getIntervalLabel({ recurringInterval: 'year' }); // 'Yearly'
getIntervalLabel({ recurringInterval: 'month', recurringIntervalCount: 3 }); // 'Every 3 months'
// Compare-at / sale price
hasCompareAt({ amount: 1500, compareAtAmount: 2000 }); // true
getDiscountPercent({ amount: 1500, compareAtAmount: 2000 }); // 25
getSavingsAmount({ amount: 1500, compareAtAmount: 2000 }); // 500 (cents)
// Price type labels
getPriceTypeLabel({ type: 'recurring', recurringInterval: 'month' }); // 'Subscription'
getPriceTypeLabel({ type: 'one_time' }); // 'One-time'
// Free price detection
isFree({ amount: 0 }); // true
isFree({ amount: 1999 }); // falseAPI
isRecurring(price): boolean
Check if a price is a recurring/subscription price. Accepts both type: 'recurring' and the presence of a recurringInterval / recurring_interval field.
getIntervalLabel(price): string
Get a human-readable interval label. Returns 'Monthly', 'Yearly', 'Weekly', 'Daily' for count=1, or 'Every N months/years/weeks/days' for higher counts.
hasCompareAt(price): boolean
Check if a price has a compare-at (was/original) price higher than the current price.
getDiscountPercent(price): number
Calculate the discount percentage (0-100) between compare-at and current price, rounded to the nearest integer.
getSavingsAmount(price): number
Calculate the savings amount in cents (compare-at minus current price).
getPriceTypeLabel(price): string
Returns 'Subscription' for recurring prices, 'One-time' for everything else, or '' for null/undefined.
isFree(price): boolean
Check if a price is free (amount is 0 or missing).
getProductPricing(product): ProductPricingSummary
Pure-math pricing summary for an entire product (one step up from the price-level helpers above). Returns numeric fields only — pair with @arraypress/stripe-currencies for formatting.
Resolves:
- The display tier for products with
variations(default-flagged tier, falling back to first) - On-sale detection (
salePrice < priceand both > 0) - Savings amount + percent (0–100)
- CompareAt anchor against post-sale display price
- Recurring flag from a
recurringblock
Defensive — negative numbers clamp to 0, missing fields default sensibly.
import { getProductPricing } from '@arraypress/price-utils';
getProductPricing({ price: 49, salePrice: 29 });
// { displayPrice: 29, originalPrice: 49, onSale: true,
// savings: 20, savingsPct: 41, isRecurring: false,
// hasCompareAtPrice: false, compareAtSavings: 0,
// compareAtSavingsPct: 0 }
getProductPricing({
price: 199,
salePrice: 99,
compareAtPrice: 450,
});
// { displayPrice: 99, originalPrice: 199, onSale: true,
// compareAtSavings: 351, compareAtSavingsPct: 78 }normalizeBadges(product, options?): NormalizedBadge[]
Normalise a product's badges field — a mixed array of slug strings + inline objects — into a consistent { label, tone, icon? } shape.
Pass a resolveBadge(slug) callback to look slug strings up against your theme's registry. When pricing is provided and autoSale is true (default), a -X% SALE badge is prepended on discounted products.
import { normalizeBadges, getProductPricing } from '@arraypress/price-utils';
const REGISTRY = {
bestseller: { label: 'Bestseller', tone: 'accent', icon: 'ti-flame' },
new: { label: 'New', tone: 'success', icon: 'ti-sparkles' },
};
normalizeBadges(product, {
resolveBadge: (slug) => REGISTRY[slug] ?? null,
pricing: getProductPricing(product),
});byFeatured(a, b), byPrice(a, b), byPriceDesc(a, b), byName(a, b)
Sort comparators. Drop directly into Array.prototype.sort. All four accept both raw product objects and Astro CollectionEntry-shaped wrappers (item.data.field).
import { byFeatured, byPrice, byPriceDesc, byName } from '@arraypress/price-utils';
products.sort(byFeatured); // by `order` field, ascending
products.sort(byPrice); // cheapest first, sale-aware
products.sort(byPriceDesc); // priciest first
products.sort(byName); // alphabetical, locale-awarebyPrice / byPriceDesc use the same display-tier resolution as getProductPricing, so multi-tier products sort by their default tier's display price.
Field Name Compatibility
All functions accept both camelCase and snake_case field names:
| camelCase | snake_case |
|-----------|------------|
| recurringInterval | recurring_interval |
| recurringIntervalCount | recurring_interval_count |
| compareAtAmount | compare_at_amount |
License
MIT
