WooCommerce Subscriptions is a bundled integration like any other: every function guards
on the plugin, and with it absent nothing in this document exists. What follows is the
reasoning behind the screen, not a tour of it. Read woocommerce-orders.md first. A
subscription wears the order detail's shape on purpose, and the two share their
implementation rather than resembling each other.
It used to be a modal and nothing else. Now /minn-admin/subscriptions/{id} is the
primary surface: a URL you can link, reload, and hand to someone. The modal survives as
Quick view behind the row's eye, for the case it was always good at, which is a glance
without losing your place in the list.
Both hosts render one body (subscriptionDetailInnerHtml) and run one binder
(bindSubscriptionDetail). There is no second implementation to drift. The page lays out
two columns and the modal stacks them, which is the only difference between the two.
The schedule, the customer, the payment method, and the attribution card. Attribution is
literally the order card: WooCommerce Subscriptions records the same
_wc_order_attribution_* meta on a subscription that WooCommerce records on an order, so
attributionCardHtml() serves both. As on an order, absent meta means no card rather than
an invented "direct".
This is the trap the whole surface turns on. WooCommerce Subscriptions reads dates as
next_payment_date_gmt, trial_end_date_gmt, start_date_gmt. It writes them as
next_payment_date, trial_end_date, and interprets whatever it is given as GMT. Two
vocabularies for one field, and no suffix on the side where the ambiguity would actually
hurt.
So every read goes through subTime(), which forces UTC, and every write goes through
siteInputToGmt(). Getting this wrong is invisible on a site whose timezone is UTC, which
is exactly how it survived unnoticed: on a site at UTC-5 a subscription that started
seconds ago rendered as "in 5h", a subscription that has not started yet. The tests assert
against a computed offset rather than a literal, so they fail on any site where the
conversion is dropped.
The dialog shows site time, like every other date in Minn. The value it holds is the
picker's own machine format, and Minn's picker is used rather than a native
datetime-local, which Chrome refuses to style.
Items and schedule are editable; the sidebar cards are read-first with a pencil, as on an order.
Items are not a second implementation. A subscription's line_items take the byte
identical PUT an order's do, so bindItemsDialog() is parameterized by route rather than
copied: same product search, same quantity rescale from the unit price, same
quantity: 0 removal. The pencil appears only when WooCommerce says is_editable, which
is WooCommerce's judgment to make and not ours.
The schedule sends a diff. An untouched field is never transmitted, so WooCommerce
never validates a date the user did not open the dialog for. It enforces the order
trial end ≤ next payment ≤ end and rejects anything else with a 400, which surfaces as
the error toast rather than as a silent no-op.
Coupons edit the set, not one coupon. WooCommerce's coupon_lines is declarative: the
array you PUT replaces every coupon on the record, sending an existing line's id is
refused outright (coupon_item_id_readonly), and an empty array removes them all. The
dialog therefore collects codes and sends codes, which is also why removal needs no
special call.
Nothing here does arithmetic. WooCommerce recalculates discount_total, the record total
and the line totals on apply, and puts them back on removal; the dialog re-reads the record
and repaints. It also owns the refusals, and they are better than anything we would write:
"only recurring coupons can be applied to subscriptions", "not applicable to selected
products", "does not exist". They reach the user verbatim.
The consequence worth knowing: a subscription takes recurring coupon types only
(recurring_percent, recurring_fee), and only over products that are actually
subscription products. A plain percent coupon is refused, correctly.
Like items, the button appears only when WooCommerce says is_editable. That matches
wp-admin, which hides its own Apply coupon on an order that is no longer editable.
Start date is deliberately read only. Moving a live subscription's start rewrites the billing history hanging off it. It is shown in the dialog for reference and cannot be changed there. Making it editable is a decision to take on purpose, not a gap to fill.
The pencils carry data-soedit, not the order surface's data-oedit. bindOrderDetail
queries [data-oedit] across the whole document, so a shared name would let an order host
bind controls that are not its own.
Related orders offer both moves. Clicking the reference navigates, which is what an order reference does everywhere in Minn. The eye beside it opens the order as a quick view instead, stacked over the subscription, so checking what a renewal charged does not cost the page you were reading. From the quick view there is only one modal to give, so the order replaces the subscription in it, the same way a related subscription behaves on the order side. The two buttons are siblings, never nested: a button inside a button is invalid and browsers drop the inner one.
The timeline is the order's card, pointed at the subscription. WooCommerce gives both
records the same note shape ({ note, customer_note }) under their own route, so the
composer, the private/customer split and the list are one helper used twice. What is not
shared is the ids: an order quick view can stack over a subscription page, putting two
timelines in one document, and a shared id would let one host's binder drive the other's
composer (the page's, since it comes first in the DOM). Orders keep minn-o-*,
subscriptions take minn-s-*, and the subscription's binder is scoped to its own view.
Notes arrive on their own request, like renewal orders: a failure leaves the card empty instead of breaking the detail. WooCommerce writes its own notes into the same timeline (status changes, renewal results), which is the point of showing it here at all.
Back follows the trail, not the record type. An order reached from a subscription says
← Subscription #123 and returns there; the same order reached from the orders list says
← Orders. The trail is left at the click and spent when the order page reads it, so it
cannot outlive the visit that set it, and a reload leaves none, which is why a deep link
falls back to the list. Landing on the orders list after opening a renewal stranded you
somewhere you had never been.
Subscriptions run the same filter machinery as orders, with their own vocabulary: their own statuses (active, pending-cancel, expired, switched) against the same native collection parameters. Filters live in the URL and survive a reload. The two lists never share filter state.
Columns follow WooCommerce's own list: subscription, customer, items, status, start date,
next payment, total. The items cell shows the first product and a +N, with the rest one
hover or click away. That cell swallows its own clicks so the list can open the popover
instead of navigating, which means a click in the middle of a row does not open the
subscription. That is Shopify's behavior and it is deliberate.
An order that belongs to a subscription says so: the one that started it reads
Subscription, the ones it has billed since read Renewal. Hover or focus the badge and a
popover names the subscription, its status, its billing period, its recurring total and its
next payment, with a button into it.
The badge rides beside the order number instead of taking a column. Most stores have no subscriptions at all, and a column would sit empty on every row to serve a few.
The relation is WooCommerce Subscriptions' to know, so the server asks it through
wcs_get_subscriptions_for_order, which understands both storage layouts. Minn never reads
_subscription_renewal itself. It resolves a whole page in one request
(minn-admin/v1/wc/orders/subscription-relations?ids=…) and remembers what it learned,
including which orders are unrelated, so a re-render asks for nothing. The alternative was
adding meta_data to the list's _fields, which drags every row's entire meta bag across
the wire to read one key.
It is an enrichment, never a gate: the list paints first and the badges arrive after.
Switching a subscription's product, retrying a failed renewal, changing payment method on behalf of a customer, and everything a payment gateway owns. The link out is one click and is never hidden.
tests/subscription-page.test.js covers the page: the deep link, the two columns and
their stacking, the attribution card, the status save, items editing asserted against what
WooCommerce stored, schedule editing asserted as GMT against the site's own offset, the
themed picker, related-order navigation with the Back trail it leaves, the quick view that
opens an order without leaving the subscription, notes asserted as WooCommerce stored them
(private and customer-visible), coupons both ways (a recurring one applied over a real
subscription product, a plain one refused in WooCommerce's words), and the modal as Quick
view.
tests/subscription-filters.test.js covers the list: each filter against the rows and the
query string, the URL round trip, the start date column, and the GMT reading of a start
date. tests/wcs-subscriptions.test.js covers the integration underneath both, including
the orders list badges: parent, renewal, an unrelated order left unbadged, and the popover
as a way into the subscription.