@doany-ai/commerce
v0.1.0
Published
Orders, checkout and subscriptions on a Doany site: order and payment hooks, embedded Stripe checkout, order summary and payment status, subscribe and billing buttons
Readme
@doany-ai/commerce
Orders, checkout and subscriptions on a Doany site. Prices, totals, whether an order can still be paid, and when it counts as placed are the platform's to decide; this package places orders, shows them, mounts Stripe's form for them and asks the platform what became of the payment. A plan billed every month or year is sold as a subscription (see Subscriptions).
Needs @doany-ai/app-core (DoanyProvider, AuthProvider) and a client from
@doany-ai/sdk 0.3.0-alpha.0 or later. It ships no CSS: the site's Tailwind
build scans it (see @doany-ai/app-core).
Selling something: the pages
import { CatalogGrid, CatalogItemDetail } from "@doany-ai/catalog";
import { BuyButton, PaymentStatus, useMyOrders } from "@doany-ai/commerce";
// List page: each card opens its item page through the site's router.
<CatalogGrid
hrefFor={(item) => `/services/${item.id}`}
renderLink={({ href, className, children }) => <Link to={href} className={className}>{children}</Link>}
/>
// Item page: the chosen variant goes to the buy button.
<CatalogItemDetail itemId={id} actions={(variant) => variant && (
<BuyButton
items={[{ catalog_item_variant_id: variant.id }]}
onPlaced={(orderId) => navigate(`/orders/${orderId}`)} // paid on the page, or free
successPath={(orderId) => `/orders/${orderId}`} // a redirect-based payment comes back here
/>
)} />
// /orders/:orderId — where a paid order lands. Reads the order with this
// browser session's token, or with the token of an order link (below).
<PaymentStatus orderId={orderId} />
// A signed-in customer's own orders.
const { orders, isLoading, hasNextPage, fetchNextPage } = useMyOrders();Write both onPlaced and successPath: a card payment finishes on the page
and calls onPlaced; a payment method that leaves the site comes back to
successPath. The platform adds nothing to that path, so a return page that
needs the order must name it there. It is a path, not the order link: a #t=
(or any hash) given in it is dropped before the platform sees it — the whole
return path goes to the payment provider, and the return page finds the token
again in this tab.
A catalog item can be bought only when it is on sale (published, with direct
purchase on). If it is not, placing the order fails and BuyButton shows
the notPurchasable label.
Selling only once payments are connected
A catalog item says purchasable before the business has connected its
payment account, and the published site can take no money until then (the
preview's test orders always work). A page that should only take inquiries
until payments are live asks the platform, and switches to a BuyButton once
the answer is yes — without this, a customer finds out only after placing an
order, when the checkout refuses with PAYMENTS_NOT_LIVE:
import { BuyButton, useCheckoutAvailability } from "@doany-ai/commerce";
function ItemActions({ variant }) {
const { available, isLoading } = useCheckoutAvailability();
if (isLoading) return null;
return available
? <BuyButton items={[{ catalog_item_variant_id: variant.id }]} />
: <Link to={`/inquire?item=${variant.catalog_item_id}`}>Ask for a quote</Link>;
}available is the platform's answer for the site the page is served from:
the preview needs only the site; the published site needs the business's live
payment account. The answer is kept for a minute per page load.
Subscriptions
A plan billed every week, month or year (a catalog Plan with an option whose
billing is recurring) is not an order: BuyButton refuses it.
SubscribeButton sells it: the platform starts a subscription to the option
and the button opens Stripe's checkout for its first period on the page in a
dialog (it works inside the editor's preview too). A subscription is the
platform's record, kept on the account's contact; Stripe keeps it up to date
(renewals, a failed card, a cancellation) and the platform mirrors it. The
rights the plan's benefits grant are platform records too (useEntitlement).
a guest's button reads Sign in to subscribe and goes to the site's sign-in page, coming back here after;
once Stripe is done, the button reads the subscription back until its first period is paid (
active), usually 1–3 seconds; a slow payment method says Your payment is processing and the subscription turns active on its own;the account's subscription to the option (or to the plan, when
planIdis given), as the platform answers it:activeortrialing: greyed out, Already subscribed;past_due,unpaidorpaused: Stripe is still collecting it, so a second one could bill twice: greyed out, Update your payment in Manage billing, with aManageSubscriptionButtonbeside it;incomplete(started, not paid): the button opens the same checkout again;canceled,expired, or none: the button subscribes.
The platform refuses a second subscription to a plan whichever option it is for (
already_subscribed), and the page's reads follow. Updating the card and canceling happen on Stripe's billing page (ManageSubscriptionButton);a payment method that leaves the page (a bank's own page) comes back to
successPath(default: this page); theSubscribeButtonfor the same option there finishes the checkout this tab opened (kept in the tab'ssessionStorage, as an order link's token is);while the site cannot take payments (
useCheckoutAvailability), the button is greyed out and reads Subscriptions are not open yet;an option not sold on the site, or bought once rather than billed every period, is refused by the platform when the checkout is asked for, in its own words (
planUnavailable,notASubscription).
Every one of these is useSubscriptionCheckout's to refuse (blocked):
start() opens nothing while any applies, so a page built on the hook refuses
what the button refuses.
import { CatalogItemDetail } from "@doany-ai/catalog";
import { BuyButton, ManageSubscriptionButton, SubscribeButton, useEntitlement, useSubscription } from "@doany-ai/commerce";
// Item page: an option billed every period subscribes; anything else is ordered.
<CatalogItemDetail itemId={id} actions={(variant, item) =>
variant && (variant.billing === "recurring"
? <SubscribeButton optionId={variant.id} planId={item.id} onSubscribed={() => navigate("/account")}>Join</SubscribeButton>
: <BuyButton items={[{ catalog_item_variant_id: variant.id }]} />)
} />
// A members-only page: gate on the benefit's key, read again on every visit.
function Members() {
const { active, endsAt } = useEntitlement("member-videos"); // the site feature the plans' access benefit opens
if (active === undefined) return null; // still asking (or the read failed: isError)
if (!active) return <Link to="/pricing">See plans</Link>; // none, ended, or suspended while a renewal is owed
return <p>Member until {new Date(endsAt).toLocaleDateString()}</p>;
}
// The account page: the plan's subscription and its billing.
function Account() {
const { subscription, active, paymentProblem } = useSubscription(planId);
if (paymentProblem) return <ManageSubscriptionButton>Update your card</ManageSubscriptionButton>;
if (!active) return <Link to="/pricing">See plans</Link>;
return (
<>
{subscription.cancel_at_period_end && <p>Ends {new Date(subscription.current_period_end).toLocaleDateString()}</p>}
<ManageSubscriptionButton variant="outline" />
</>
);
}Gate on active — a right's (useEntitlement) or a subscription's
(useSubscription) — never on a value kept from the day they subscribed:
renewals, a failed card and a cancellation in the billing portal change it
without the site doing anything. A canceled subscription stays active (with
cancel_at_period_end) until the period paid for ends; a right is suspended
while a renewal is owed and active again once it is paid. Prefer the right's
key where a site sells several plans: "has a subscription" cannot tell one
plan's content from another's.
SubscribeButton
Takes every Button prop except onClick and children (variant, size,
disabled, …).
| Prop | Type | |
| --- | --- | --- |
| optionId | string | required. The plan's option: its catalog_item_variant_id, one billed recurring |
| planId | string | the plan the option belongs to (its catalog item id): a subscription to any of its options reads Already subscribed before the click |
| successPath | string | where a payment method that leaves the page (a bank's own page) comes back to. Default: this page. The SubscribeButton for the same option on that page finishes the subscription |
| children | ReactNode | the button's text while it can subscribe (and while it is still reading). Wins over subscribe. Each blocked reason gets its own words instead: signInToSubscribe, subscribeUnavailable, alreadySubscribed, paymentProblem |
| onSubscribed | (result: SubscriptionResult) => void | the subscription is active (paid: true), or its first payment is on its way (paid: false; it turns active when the payment settles): { subscriptionId, subscription, paid }. Fires even if the dialog was closed meanwhile |
ManageSubscriptionButton
Opens Stripe's billing page (cancel, update the card, the invoices) for the
signed-in account's own subscription in a new tab: Stripe has no form to embed
for it, and the editor's preview cannot be sent to it. The tab is opened on the
click itself, so neither a popup blocker nor the preview's frame stops it; when
the browser still refuses, a link to the page appears beside the button.
Renders nothing when there is no billing to manage: a guest, or an account
whose subscriptions never got past their first checkout. Takes every Button
prop except onClick and children.
| Prop | Type | |
| --- | --- | --- |
| returnPath | string | where Stripe's page sends them back to: a path on this site. Default: this page |
| children | ReactNode | the button's text. Wins over manageBilling |
Order links
An order link carries the order's access token after the #:
/orders/<id>#t=<access_token>. A guest opening their order from another
browser needs it. The hooks and components here read it themselves when no
accessToken is given: from the address when the order's id is in it, else
from the one this tab kept from such a link (so a payment that leaves the site
and comes back without the hash still finds it). readOrderLinkToken(orderId)
returns it for a page that calls the SDK itself.
Components
Every component takes labels (a part of CommerceLabels, see below) and
className. Money is shown with locale (default: the browser's).
BuyButton
Buyer details → order → payment, in one control. A signed-in account orders as
itself; a guest is asked for a name and email. Free orders are placed at once.
Takes every Button prop except onClick and children (variant, size,
disabled, …).
| Prop | Type | |
| --- | --- | --- |
| items | { catalog_item_variant_id, quantity? }[] | required. Catalog variants, each once |
| buyer | { name, email?, phone? } | who it is for. Default: the signed-in account, else the form asks |
| customerNote | string | |
| requireShipping | boolean | something is shipped (kind: "physical"): the form asks for an address |
| shippingAddress | ShippingAddress | where to ship, when the site already knows |
| successPath | string \| (orderId) => string | where a redirect-based payment comes back: a path on this site; a hash in it is dropped. Default / |
| children | ReactNode | the button's text. Wins over buyNow |
| onOrderCreated | (result: CreateOrderResult) => void | the order exists (pending, or placed when free) |
| onPlaced | (orderId) => void | placed: free at once, or after the payment is recorded |
| onPending | (orderId) => void | paid by a method that has not settled yet |
PaymentStatus
The page after paying: asks the platform until the order is placed (usually
1–3 seconds), then says what became of the payment, with the order under it:
paid (Payment received), processing (a slow method), due (placed but
still owing — an order the business entered before it was paid: Awaiting
payment, never Payment received) or unpaid (expired or canceled).
| Prop | Type | |
| --- | --- | --- |
| orderId | string | required |
| accessToken | string | default: the order link's #t=, else this browser session's |
| showSummary | boolean | show the order under the status. Default true |
| messages | { [outcome]: { title: ReactNode, body?: ReactNode } } | replaces the heading and text per state; each entry is an object with a title and an optional body, keyed by checking, paid, processing, due or unpaid. Wins over the labels: messages={{ paid: { title: "Thank you!", body: "Your download is below." } }} |
| children | (outcome) => ReactNode | under everything: links onward, a retry |
| locale | string | |
OrderSummary
An order's lines, total and status.
| Prop | Type | |
| --- | --- | --- |
| order | Order | shown as given… |
| orderId, accessToken | string | …or read by id (token: as PaymentStatus) |
| title | ReactNode | default Order #12, or Your order before it has a number |
| showBuyer | boolean | show the buyer's name and email |
| footer | ReactNode | under the total: actions, a note |
| locale | string | |
OrderStatusBadge
status (OrderStatus) as a badge, worded by the status* labels.
OrderCheckout, CheckoutDialog
OrderCheckout pays a pending order on the page: useOrderCheckout with
default words around CheckoutForm. CheckoutDialog is the same in a dialog.
| Prop | Type | |
| --- | --- | --- |
| orderId | string | required. CheckoutDialog: null closes it |
| accessToken, successPath, onPlaced, onPending, onError, confirmTries, confirmSpacing | | as useOrderCheckout |
| settled | (outcome: "placed" \| "pending") => ReactNode | replaces the text once settled |
| onClose | () => void | CheckoutDialog only: whenever it closes, paid or not |
| title, description | ReactNode | CheckoutDialog only. title wins over checkoutTitle |
CheckoutForm
Stripe's form alone, for a checkout from useOrderCheckout or
useSubscriptionCheckout: checkout (required). Stripe's frame is Stripe's own; its colors are set in the Stripe
account's branding settings.
BuyerForm
The name and email form BuyButton uses, for a page that places orders itself.
| Prop | Type | |
| --- | --- | --- |
| onSubmit | (buyer: { name, email, shipping? }) => void | required |
| defaultValues | Partial<Buyer> | |
| askShipping | boolean | also ask where to ship |
| submitLabel | string | wins over continueToPayment |
| busy | boolean | |
| error | string \| null | shown above the button |
Hooks
| Hook | Returns |
| --- | --- |
| useCreateOrder() | a React Query mutation: mutateAsync({ items, buyer, shipping_address?, customer_note? }) → { order, access_token?, payment_required }. payment_required: false means free and already placed. The SDK keeps a guest's order token for the browser session |
| useOrder(orderId, { accessToken? }) | a query; data is the Order |
| useMyOrders({ status?, limit? }) | the signed-in account's orders, newest first: orders (all pages loaded so far), isLoading, hasNextPage, fetchNextPage(). Nothing for a guest |
| useCancelOrder() | a mutation: mutateAsync({ orderId, version?, accessToken? }) → the canceled Order. Only an order still waiting for its payment |
| useOrderPayment(orderId, { poll?, interval?, timeout?, accessToken? }) | a query (data is the OrderPayments), plus outcome: paid (placed or completed, and payment_status is paid or no_charge) / processing (pending) / due (placed but still owing: entered by the business, or paid in part) / unpaid (expired or canceled), and waiting (still asking). Asks again every interval ms (1500) while pending — and while a placed order still owes with a payment open or in flight (one the business entered, paid on the page a moment ago) — up to timeout (60 000) |
| useCheckoutAvailability() | a query, plus available: whether a checkout could open now on this site (undefined until answered). See Selling only once payments are connected |
| useOrderCheckout(orderId, options) | see below |
| useSubscription(planId?) | the signed-in account's subscription to a plan (planId: the Plan's catalog item id), or all its subscriptions: a query (data is the list), plus subscriptions (the plan's), subscription (the one that counts now: an active one, else one still being collected, else one not paid yet, else the latest ended), active, paymentProblem, checking. active: true while active or trialing, false for a guest or an account without one, undefined until answered (or when the read failed: isError). paymentProblem: not active and Stripe is still collecting it (past_due, unpaid, paused): send them to ManageSubscriptionButton, not to the plans. Read again on every mount; one read per page, shared by every hook on it |
| useEntitlement(key?) | a right the signed-in account holds, by the site feature a Plan's access benefit opens (feature_key, shared by the monthly and yearly plans) or a benefit's grant_key, or all its rights: a query (data is the list), plus entitlements, entitlement, active (the platform's answer: status and the window; false for a guest, undefined until read), endsAt. What a members-only page gates on: the key tells one plan's content from another's |
| useSubscriptionCheckout(optionId, options) | SubscribeButton without its markup: start(), reset(), retry(), blocked, phase (idle → starting → ready → confirming → subscribed / pending, or error), errorKind (unavailable, not_sellable, not_a_subscription, already_subscribed, not_confirmed, failed), retryable, stripe and stripeOptions (for <CheckoutForm checkout={…} />), session (the platform's checkout of the first period: amount_total, currency, mode), subscriptionId, result (as onSubscribed). Options: planId, successPath, onSubscribed, onError, confirmTries, confirmSpacing (reading the subscription back until it is active: 8 × 1500 ms). blocked is why start() opens nothing now, null when it opens the checkout: checking (the sign-in or the account's subscriptions are still being read, or that read failed), sign_in_required (start() goes to the site's sign-in page instead), unavailable (useCheckoutAvailability says no), already_subscribed (the account's subscription to the option, or to the plan when planId is given, is active or trialing), payment_problem (past_due, unpaid, paused); incomplete, canceled, expired or none block nothing. What the platform refuses when the checkout is asked for (not sold this way, already subscribed meanwhile) is an error with its errorKind; already_subscribed also reads the account's subscriptions again |
useOrderCheckout(orderId, { accessToken?, successPath?, onPlaced?, onPending?, onError?, confirmTries?, confirmSpacing? })
returns:
| Field | |
| --- | --- |
| phase | idle → starting → ready → confirming → placed / pending / unpaid, or error |
| error, errorKind | why it could not start: cannot_be_paid, other_environment (a preview order on the published site or back), payment_unresolved, not_found, failed |
| retryable, retry() | ask for the same order's checkout again |
| stripe, stripeOptions | for <CheckoutForm checkout={…} />, or for a site that mounts EmbeddedCheckoutProvider from @stripe/react-stripe-js itself |
| session | the platform's checkout: amount_total, currency, expires_at, mode (test / live) |
It keeps these guarantees:
- Stripe.js is loaded once per (publishable key, account), never per render;
- an answer for a previous order never lands on the current one;
- Stripe's "complete" is not "paid":
onPlacedfires only once the platform has the orderplaced(a slow method givesonPending); onPlacedfires even if the dialog was closed meanwhile, and a callback that throws cannot turn a recorded payment into anything else;- callbacks are read through a ref, so a parent's inline functions do not remount Stripe's form.
Also exported: outcomeOf(payments) (the outcome of an OrderPayments
answer), checkoutErrorKind(error), checkoutErrorText(error, labels),
orderKeys, checkoutAvailabilityKey, subscriptionKeys and entitlementKeys
(the React Query keys, for invalidating), readOrderLinkToken(orderId),
currentSubscription(subscriptions) (of an account's subscriptions, the one
that counts now), and the record types (Order, OrderItem, OrderStatus,
OrderMoney, OrderPayments, CreateOrderParams, Subscription,
SubscriptionStart, Entitlement, SubscriptionResult, …).
Order statuses: pending (placed on the site, waiting for its payment;
expires after an hour unpaid) → placed (paid, free, or entered by the
business; has an order number) → completed; or canceled / expired.
placed alone does not mean paid: an order the business entered is placed
before any money arrives. The platform says so in money.payment_status on
the order (null while pending) and payment_status on the payment answer:
paid when it is paid, or when no_charge is true (a free order); unpaid
and part_paid still owe. Amounts are integers in the currency's smallest
unit (cents).
Labels
Every word the components show is in CommerceLabels. A component takes a
part of it and keeps the defaults for the rest, and passes it on to the
components inside it (BuyButton to its form and checkout, PaymentStatus to
its summary and badge), so one object carries a site's wording or another
language everywhere. A prop that names a text (children, title,
submitLabel, messages) wins over the labels. defaultCommerceLabels holds
the defaults; commerceLabels(partial) merges.
| Where | Keys (default) |
| --- | --- |
| Buy button and its form | buyNow (Buy now), detailsTitle (Your details), detailsDescription, detailsDescriptionShipping, notPurchasable, rateLimited, orderFailed, name, email, shipping, address, city, region, postalCode, countryCode, countryPlaceholder, countryHint, continueToPayment (Continue to payment) |
| Checkout | checkoutTitle (Checkout), checkoutDescription, startingCheckout, confirmingPayment, paymentReceived, paymentProcessing, orderNotPaid, tryAgain, cannotBePaid, otherEnvironment, paymentUnresolved, orderNotFound, checkoutFailed |
| Order summary | orderNumber ((n) => "Order #" + n), yourOrder, total, refunded, free, duration ((minutes) => "45 min"), orderLoadFailed |
| Status badge | statusPending (Awaiting payment), statusPlaced (Confirmed), statusCompleted, statusCanceled, statusExpired |
| Payment status | checkingTitle, checkingBody, paidTitle (Payment received), paidBody, processingTitle, processingBody, dueTitle (Awaiting payment), dueBody, unpaidTitle, unpaidBody, paymentCheckFailed |
| Subscribe button and its dialog | subscribe (Subscribe), signInToSubscribe (Sign in to subscribe), subscribeUnavailable (Subscriptions are not open yet), alreadySubscribed (Already subscribed), paymentProblem (Update your payment in Manage billing), subscribeTitle (Subscribe), subscribeDescription, startingCheckout, confirmingSubscription, subscribed (You're subscribed. Thank you!), subscriptionProcessing, planUnavailable, notASubscription, subscriptionNotConfirmed, subscribeFailed, tryAgain |
| Manage billing | manageBilling (Manage billing), openBillingPortal (Open billing portal), billingNotFound, manageBillingFailed |
orderFailed, checkoutFailed, subscribeFailed and manageBillingFailed:
when the site does not set them, the platform's own message is shown instead of
the default.
Customizing
Stop at the first level that does what is needed:
CSS: the theme variables on
:root(see@doany-ai/app-core).Props: the props above, and
labelsfor every word:<BuyButton items={items} labels={{ buyNow: "Book", continueToPayment: "Pay deposit", statusPending: "Unpaid" }} />Hooks: build the page on
useCreateOrder,useOrder,useMyOrders,useCancelOrder,useOrderPayment, and for the paymentuseOrderCheckoutwithCheckoutForm(useSubscriptionCheckoutwithCheckoutFormfor a plan). Keep their behavior instead of calling the client yourself.const checkout = useOrderCheckout(orderId, { onPlaced }); if (checkout.phase === "starting") return <MySpinner />; if (checkout.phase === "error") return <MyError kind={checkout.errorKind} retry={checkout.retryable && checkout.retry} />; return <CheckoutForm checkout={checkout} />; // Stripe's frame, nothing around it
Never edit this package under node_modules or copy its source into the site.
Develop
npm run typecheck includes tests/sdk-contract.types.ts: the real
@doany-ai/sdk client (from ../doany-sdk) must satisfy the client type this
package calls, so a changed SDK contract fails here, not on a site.
For a managed booking order, cancellation may return status: "pending" with
cancellation.status: "processing". This means the request was accepted;
the booking is still reserved. useOrder polls that state until it finishes,
and OrderSummary shows cancellation progress. A not_canceled outcome (for
example, payment completed during cancellation) requires a new decision from
the customer. Do not automatically submit another cancellation.
useCancelOrder().mutateAsync({ orderId, version, reason?, refundDecision?, idempotencyKey? })
accepts an optional reason for any order. Keep the same idempotency key when
retrying the same request; a new confirmed cancellation uses a new key. A paid
managed order requires either its current version or an explicit key.
For an ordinary order with money still received, a business caller must choose
refundDecision: "refund" | "keep" before canceling. The API rejects a missing
decision with REFUND_DECISION_REQUIRED; managed bookings use their policy.
