fancy-catalog
A headless Stripe catalog for Python — products, prices, plans and checkout, with money as integer minor units and persistence behind adapters. No web framework, no ORM, and the Stripe SDK is injected rather than depended on.
The Python twin of particle-academy/laravel-catalog
(PHP) and @particle-academy/fancy-catalog
(Node/TypeScript).
pip install fancy-catalog
pip install "fancy-catalog[features]" # + the entitlement bridge
In one minute
import stripe
from fancy_catalog import create_catalog
# NOTE the .v1 -- see "Passing a Stripe client" below.
catalog = create_catalog(stripe=stripe.StripeClient(api_key).v1)
pro = catalog.create_product("Pro plan", description="Everything, monthly")
price = catalog.create_price(pro.id, currency="USD", amount="19.99", recurring_interval="month")
price.unit_amount # 1999 -- exactly, not 1998
catalog.sync_product_and_prices(pro)
catalog.subscription_checkout_url(price, customer="cus_123", success_url="...", cancel_url="...")
Money is the point
Stripe stores an amount as an integer in the currency's smallest unit. A human types a decimal string. The conversion between them is where money is lost, and neither twin owns it — so every consumer writes it themselves:
int(19.99 * 100) # 1998. One cent, on every order.
round(8.615 * 1000) # 8614. Rounding does not fix it, it moves it.
from fancy_catalog import to_minor_units, format_minor_units, currency_exponent
to_minor_units("19.99", 2) # 1999
to_minor_units("8.615", 3) # 8615
to_minor_units("1000", currency_exponent("JPY")) # 1000, not 100000
to_minor_units("1.005", currency_exponent("KWD")) # 1005
format_minor_units(-7, 2) # "-0.07"
to_minor_units(19.99, 2) # TypeError -- a float has already lost it
to_minor_units("0.005", 2) # MoneyPrecisionError -- rounds nothing silently
Pinned by the shared
shared/money-minor-units
conformance table, so a second implementation in any language inherits the
behaviour rather than the assumption.
Prices are immutable, and this package knows it
A change to the amount, currency, interval, billing scheme, tiers,
transform_quantity or custom_unit_amount archives the Stripe price and
creates a replacement; a shared ULID in metadata.price_id keeps the two
linked. A change to only the metadata, active flag or lookup key updates in
place — and the lookup key is transferred, which is what stops the next
reprice failing with "lookup key already exists".
price.unit_amount = 2999
catalog.sync_price(price) # old price archived, new one created
price.external_id # a NEW Stripe id
Passing a Stripe client
stripe is not a dependency. The client is injected and used through six
operations declared in fancy_catalog.stripe_client.
catalog = create_catalog(stripe=stripe.StripeClient(api_key).v1)
Use .v1. On stripe-python 15.x the bare StripeClient.products still
works but emits a DeprecationWarning on every access, and a host running with
warnings-as-errors would see a catalog sync crash.
Any object with the same shape works — your own wrapper, a proxy, or a recorded cassette. That is how this package's own suite runs entirely offline.
Persistence
ProductStore, PriceStore and ProductFeatureStore are protocols with
in-memory defaults. Soft deletes mirror the PHP SoftDeletes trait, because an
invoice referencing a hard-deleted product is an invoice nobody can explain.
catalog = create_catalog(stripe=client, products=MyProductStore(), ...)
Gating on what a plan includes
from fancy_catalog.features import create_catalog_feature_source
from fancy_features import create_features
features = create_features(
sources=[create_catalog_feature_source(catalog, resolve_subscription=lookup)],
)
features.can_access("use-mcp", user) # via the user's plan's product features
features.remaining("ai-tokens", user) # includedQuantity − usage
The bridge imports the shared contract from fancy-features rather than
mirroring it, so there is exactly one definition of FeatureGrant in the pair.
fancy_catalog itself never imports the bridge — a host with no gating layer
installs nothing extra.
Requirements
Python 3.11+. No required runtime dependencies.
License
MIT © Particle Academy
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fancy_catalog-0.2.0.tar.gz.
File metadata
- Download URL: fancy_catalog-0.2.0.tar.gz
- Upload date:
- Size: 43.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a63d30b2ffede935cc7f91b04f99c95fc24f143c1bd2ea4be1318b7df65f09fc
|
|
| MD5 |
e52f576461ecb29ce6e9eb72538e5365
|
|
| BLAKE2b-256 |
da42685b5eb023f9785141c1164805c33f93292b3b2199b878da1e2a64edc982
|
Provenance
The following attestation bundles were made for fancy_catalog-0.2.0.tar.gz:
Publisher:
publish.yml on Particle-Academy/fancy-catalog-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fancy_catalog-0.2.0.tar.gz -
Subject digest:
a63d30b2ffede935cc7f91b04f99c95fc24f143c1bd2ea4be1318b7df65f09fc - Sigstore transparency entry: 2519655876
- Sigstore integration time:
-
Permalink:
Particle-Academy/fancy-catalog-py@04cd088bde76d388f3013d4b179196d18ddec332 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Particle-Academy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@04cd088bde76d388f3013d4b179196d18ddec332 -
Trigger Event:
push
-
Statement type:
File details
Details for the file fancy_catalog-0.2.0-py3-none-any.whl.
File metadata
- Download URL: fancy_catalog-0.2.0-py3-none-any.whl
- Upload date:
- Size: 29.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f59c19516286948510a3bddeeacbc036884eec52c8fb299cd5346476eea154d
|
|
| MD5 |
9a17c9d9c3bdd7ad7ee473a8bbba5a3e
|
|
| BLAKE2b-256 |
6540b228464c32d083ac350f80292527193eb4e864f158086c6ce144f42b5fae
|
Provenance
The following attestation bundles were made for fancy_catalog-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on Particle-Academy/fancy-catalog-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fancy_catalog-0.2.0-py3-none-any.whl -
Subject digest:
4f59c19516286948510a3bddeeacbc036884eec52c8fb299cd5346476eea154d - Sigstore transparency entry: 2519655910
- Sigstore integration time:
-
Permalink:
Particle-Academy/fancy-catalog-py@04cd088bde76d388f3013d4b179196d18ddec332 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Particle-Academy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@04cd088bde76d388f3013d4b179196d18ddec332 -
Trigger Event:
push
-
Statement type: