Skip to content

feat(programs): archive/un-archive a program's Shopify listing#955

Open
jee7s wants to merge 1 commit into
mainfrom
feat/shopify-listing-archive
Open

feat(programs): archive/un-archive a program's Shopify listing#955
jee7s wants to merge 1 commit into
mainfrom
feat/shopify-listing-archive

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds a board/sysadmin action to archive (retire) and un-archive a program's Shopify
listing
, on the program-ops → Shopify section. Archiving takes a program's Shopify
product out of service without touching sales history or existing participant records;
un-archiving restores checkout. Design: checkin-app/docs/designs/SHOPIFY_LISTING_ARCHIVE.md.

Both sides of the linkage are acted on, in order (Shopify first, then stamp checkin):

  • Shopify side: the product status is set to archived via the Admin API on archive
    (hidden from the storefront, not purchasable, data preserved) and restored to active on
    un-archive. Product id comes from Program.shopifyProductId, or is derived from a variant
    (GET variants/{id}.json → product_id) for the manual-repair case, mirroring
    adjustProgramInventory.
  • Checkin side: new nullable Program.shopifyArchivedAt (@sensitivity:internal).

Decisions (from the interview)

  • Archiving = retire the linkage in checkin AND act on Shopify. Doing one side only would
    drift. archived (not draft) is the semantic match for "retired" and round-trips to
    active.
  • Board/sysadmin only, independent of program archiving. A sibling PR adds program
    archiving; the design names the integration point — a future program-archive hook should
    chain into this route / setProgramListingArchived.
  • Sales history + existing participant records untouched. Un-archive restores checkout.

What "archived" gates (treat the program as having no live listing)

While shopifyArchivedAt is set, cleanly (no stray 4xx / warnings / silent decrements):

Surface Behavior
Checkout-link building (public enroll page) No cart link built.
Member discount minting (POST .../discount-code) { code: null } → undiscounted fallback.
Capacity pushes (adjustProgramInventory) Success no-op — the single choke point, so one guard covers PATCH cap edits, scholarship holds/releases, and the webhook's sibling mirror.
sync-shopify repair 400 "un-archive first".

Not gated: the orders/paid webhook still processes late-arriving paid orders — money has
already moved, so the participant is still activated (only the inventory side-effects no-op).

On a Shopify failure the checkin side is archived anyway, reported via reportShopifyFailure
(IntegrationErrorLog + admin email), and the route returns a warning (reconcile by retry or
in the Shopify admin). setProgramListingArchived never throws — mock branch on
config.shopifyMockActive(), hard fetch timeout, same idioms as the rest of lib/shopify.ts.

Testing evidence

  • Unit (src/lib/__tests__/shopify.test.ts, +8): setProgramListingArchived archive→
    archived / un-archive→active, product-id-from-variant derivation, no-listing no-op, mock
    branch, missing-creds false, failure path (returns false + emails admins); plus
    adjustProgramInventory archived no-op. 34 pass.
  • Integration (src/app/__tests__/programArchiveShopifyAPI.integration.test.ts, new, 9
    pass
    ): authz (401 anon / 403 plain user), archive stamps+audits, un-archive clears,
    idempotent no-op, no-listing 400, bad-body 400 / missing 404, and cross-route gating —
    sync-shopify 400 while archived then repairs once un-archived, discount-code { code: null }
    while archived, PATCH capacity push suppresses the inventory warning while archived (live
    control still warns).
  • Full pre-commit suite (jest --ci --runInBand, ~74s) green; tsc --noEmit and
    eslint --max-warnings 0 clean.

Migration

20260708030000_shopify_listing_archive — additive, nullable, expand-only, no backfill
(every existing row reads as "live"). Regenerated classifications.ts committed.

Deferred

Program-archive chaining (integration point named), inventory reconcile on un-archive (manual
sync), bulk archive.

🤖 Generated with Claude Code

Adds a board/sysadmin action (program-ops → Shopify section) to retire a
program's Shopify listing and restore it, without touching sales history or
existing participant records. On archive the Shopify product is set to
`archived` status via the Admin API; on un-archive it is restored to `active`.

New Program.shopifyArchivedAt (nullable, @sensitivity:internal). While set,
every app-side surface that would touch a live listing treats it as absent:
checkout-link building, member discount minting, capacity pushes
(adjustProgramInventory — the single choke point, so one guard covers PATCH cap
edits, scholarship holds/releases and the webhook's sibling mirror), and the
sync-shopify repair path. The orders/paid webhook still processes late-arriving
paid orders (money already moved). On a Shopify failure the checkin side is
archived anyway, reported via reportShopifyFailure, with a warning for the
operator to reconcile.

See docs/designs/SHOPIFY_LISTING_ARCHIVE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant