@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/apps/storefront/astro.config.mjs +59 -0
  2. package/apps/storefront/env.d.ts +24 -8
  3. package/apps/storefront/package.json +1 -1
  4. package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
  5. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  6. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
  7. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
  9. package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
  10. package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
  11. package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
  12. package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
  13. package/apps/storefront/src/config/devTenantSeed.ts +65 -16
  14. package/apps/storefront/src/config/resolver.ts +48 -1
  15. package/apps/storefront/src/layouts/Layout.astro +14 -12
  16. package/apps/storefront/src/lib/admin/README.md +2 -2
  17. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
  18. package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
  19. package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
  20. package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
  21. package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
  22. package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
  23. package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
  24. package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
  25. package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
  26. package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
  27. package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
  28. package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
  29. package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
  30. package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
  31. package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
  32. package/apps/storefront/src/lib/edgeCache.ts +7 -6
  33. package/apps/storefront/src/lib/health.ts +53 -0
  34. package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
  35. package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
  36. package/apps/storefront/src/lib/sections.ts +19 -23
  37. package/apps/storefront/src/lib/securityHeaders.ts +1 -1
  38. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
  39. package/apps/storefront/src/middleware/index.ts +29 -12
  40. package/apps/storefront/src/pages/[...slug].astro +5 -6
  41. package/apps/storefront/src/pages/admin.astro +10 -3
  42. package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
  43. package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
  44. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
  45. package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
  46. package/apps/storefront/src/pages/auth/login.astro +4 -1
  47. package/apps/storefront/src/pages/auth/magic.astro +263 -43
  48. package/apps/storefront/src/pages/capabilities.astro +1 -1
  49. package/apps/storefront/src/pages/cockpit.astro +2365 -0
  50. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
  51. package/apps/storefront/src/pages/index.astro +23 -29
  52. package/apps/storefront/src/pages/products/[handle].astro +50 -20
  53. package/package.json +6 -2
  54. package/packages/public-runtime/src/capabilities.ts +97 -9
  55. package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
  56. package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
  57. package/packages/public-runtime/src/checkout-style.ts +13 -13
  58. package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
  59. package/packages/public-runtime/src/csp.ts +11 -11
  60. package/packages/public-runtime/src/customization-scripts.ts +28 -0
  61. package/packages/public-runtime/src/extension-contract.ts +73 -0
  62. package/packages/public-runtime/src/index.ts +4 -2
  63. package/packages/public-runtime/src/product.ts +57 -0
  64. package/packages/public-runtime/src/raw-html-policy.ts +35 -0
  65. package/packages/public-runtime/src/tenant.ts +14 -11
  66. package/pnpm-workspace.yaml +1 -0
  67. package/scripts/dev/checkout-watch.mjs +9 -0
  68. package/scripts/dev/file-browser.mjs +5 -1
  69. package/scripts/dev/transient-files.mjs +34 -0
  70. package/scripts/tot-dev.mjs +16 -3
  71. package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
  72. package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
  73. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
  74. package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
  75. package/apps/storefront/src/pages/api/dev/file.ts +0 -57
  76. package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
  77. package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
  78. package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
  79. package/apps/storefront/src/pages/api/dev/write.ts +0 -116
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Server-side client for tot-foxycart's order READ API (card 8585 / U2).
2
+ * Server-side client for the checkout order READ API (card 8585 / U2).
3
3
  *
4
4
  * Calls `GET /commerce/orders[/:orderId]` with the tenant's per-tenant bearer key
5
5
  * (the ToT `partnerApi.apiKey`, held as a Worker secret — NEVER shipped to the
@@ -14,10 +14,10 @@
14
14
  * `fetchImpl` is injectable so the client is unit-testable offline.
15
15
  */
16
16
  import type {
17
- FoxyOrderDetail,
18
- FoxyOrderDetailResponse,
19
- FoxyOrderListFilters,
20
- FoxyOrderListResponse,
17
+ CheckoutOrderDetail,
18
+ CheckoutOrderDetailResponse,
19
+ CheckoutOrderListFilters,
20
+ CheckoutOrderListResponse,
21
21
  } from "@tot/public-runtime";
22
22
  import type { CustomizationEnv } from "@tot/public-runtime";
23
23
 
@@ -50,13 +50,13 @@ export class OrderApiError extends Error {
50
50
  }
51
51
  }
52
52
 
53
- export interface FoxyOrderClient {
54
- listOrders(filters?: FoxyOrderListFilters): Promise<FoxyOrderListResponse>;
55
- getOrder(orderId: string): Promise<FoxyOrderDetail>;
53
+ export interface CheckoutOrderClient {
54
+ listOrders(filters?: CheckoutOrderListFilters): Promise<CheckoutOrderListResponse>;
55
+ getOrder(orderId: string): Promise<CheckoutOrderDetail>;
56
56
  }
57
57
 
58
- export interface FoxyOrderClientConfig {
59
- /** tot-foxycart proxy base, e.g. `https://foxycart-test.tokenoftrust.com`. */
58
+ export interface CheckoutOrderClientConfig {
59
+ /** Checkout order API base URL for the checkout proxy. */
60
60
  baseUrl: string;
61
61
  /** The tenant, sent as `?appDomain=` (e.g. `sample-store.example`). */
62
62
  appDomain: string;
@@ -66,10 +66,10 @@ export interface FoxyOrderClientConfig {
66
66
  }
67
67
 
68
68
  /**
69
- * Resolve the tot-foxycart proxy base URL for an environment (contract §2).
70
- * qa runs `NODE_ENV=test` the `foxycart-test` proxy; `live` prod. Kept
71
- * private so it does not collide with U11b's exported `foxyProxyBaseUrl` before
72
- * the units fold; the integrator can dedup into one shared export.
69
+ * Resolve the checkout order API base URL for an environment (contract §2).
70
+ * qa/test use the non-prod proxy; live uses prod. Kept private so it does not
71
+ * collide with U11b's exported checkout theme base URL before the units fold;
72
+ * the integrator can dedup into one shared export.
73
73
  */
74
74
  export function orderApiBaseUrl(env: CustomizationEnv): string {
75
75
  return env === "live"
@@ -98,7 +98,7 @@ function toError(status: number, body: string): OrderApiError {
98
98
  }
99
99
 
100
100
  /** Build the list querystring: appDomain (required) + any provided filters. */
101
- function listQuery(appDomain: string, filters?: FoxyOrderListFilters): string {
101
+ function listQuery(appDomain: string, filters?: CheckoutOrderListFilters): string {
102
102
  const p = new URLSearchParams({ appDomain });
103
103
  if (filters) {
104
104
  if (filters.queue) p.set("queue", filters.queue);
@@ -112,9 +112,9 @@ function listQuery(appDomain: string, filters?: FoxyOrderListFilters): string {
112
112
  }
113
113
 
114
114
  /** Create a per-request order client bound to one tenant + env. */
115
- export function createFoxyOrderClient(
116
- config: FoxyOrderClientConfig,
117
- ): FoxyOrderClient {
115
+ export function createCheckoutOrderClient(
116
+ config: CheckoutOrderClientConfig,
117
+ ): CheckoutOrderClient {
118
118
  const doFetch = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
119
119
  const base = config.baseUrl.replace(/\/+$/, "");
120
120
  const headers = { authorization: `Bearer ${config.bearer}` };
@@ -133,11 +133,11 @@ export function createFoxyOrderClient(
133
133
  return {
134
134
  async listOrders(filters) {
135
135
  const qs = listQuery(config.appDomain, filters);
136
- return getJson<FoxyOrderListResponse>(`${base}/commerce/orders?${qs}`);
136
+ return getJson<CheckoutOrderListResponse>(`${base}/commerce/orders?${qs}`);
137
137
  },
138
138
  async getOrder(orderId) {
139
139
  const qs = new URLSearchParams({ appDomain: config.appDomain }).toString();
140
- const { order } = await getJson<FoxyOrderDetailResponse>(
140
+ const { order } = await getJson<CheckoutOrderDetailResponse>(
141
141
  `${base}/commerce/orders/${encodeURIComponent(orderId)}?${qs}`,
142
142
  );
143
143
  return order;
@@ -8,30 +8,29 @@
8
8
  * the `TenantResolver` seam via `config/adminTargetTenant`), never looked up from
9
9
  * the static tenant map here: `lib/**` stays behind the `tenancy-resolver-fence`
10
10
  * so a single-tenant extraction stays a drop-in. A null tenant (or one with no
11
- * FoxyCart store) renders the panel's inert "no store" state.
11
+ * checkout store) renders the panel's inert "no store" state.
12
12
  *
13
13
  * Overrides are read from a mock store here (no live persistence yet — the save
14
- * hop is the tot-foxycart `/theme/foxy` PUT/refresh + Foxy color PATCH seam). One
15
- * example override is seeded so the panel demonstrates the theme/override/reset
16
- * states without a backend.
14
+ * hop is the commerce service's theme PUT/refresh seam). One example override is
15
+ * seeded so the panel demonstrates the theme/override/reset states without a backend.
17
16
  */
18
- import { resolveFoxyCommerce } from "@tot/public-runtime";
17
+ import { resolveManagedCheckout } from "@tot/public-runtime";
19
18
  import type { TenantConfig } from "@tot/public-runtime";
20
19
  import { referenceTheme } from "@/themes/reference";
21
20
  import { mergeTheme } from "@/themes/schema";
22
21
  import type { AdminTenantEnvelope } from "../../envelope";
23
22
  import {
24
- buildFoxySettingsFields,
25
- deriveFoxyCartSettings,
26
- type FoxyCartSettingsOverrides,
27
- type FoxySettingsField,
28
- } from "@/lib/commerce/foxyCartSettings";
23
+ buildCheckoutSettingsFields,
24
+ deriveCheckoutSettings,
25
+ type CheckoutSettingsOverrides,
26
+ type CheckoutSettingsField,
27
+ } from "@/lib/commerce/checkoutSettings";
29
28
 
30
- export interface FoxyCartSettingsViewModel {
29
+ export interface CheckoutSettingsViewModel {
31
30
  targetTenant: string;
32
- /** True when the tenant has a FoxyCart store (else the section is inert). */
31
+ /** True when the tenant has a checkout store (else the section is inert). */
33
32
  hasStore: boolean;
34
- fields: FoxySettingsField[];
33
+ fields: CheckoutSettingsField[];
35
34
  overrideCount: number;
36
35
  }
37
36
 
@@ -40,7 +39,7 @@ export interface FoxyCartSettingsViewModel {
40
39
  * generic sample tenant so the panel shows a mixed theme/override state out of
41
40
  * the box. Real overrides would come from the tenant's saved cart settings.
42
41
  */
43
- const MOCK_OVERRIDES: Record<string, FoxyCartSettingsOverrides> = {
42
+ const MOCK_OVERRIDES: Record<string, CheckoutSettingsOverrides> = {
44
43
  "sample-store.example": {
45
44
  // Example: the merchant pinned a custom tertiary accent, overriding the
46
45
  // theme's --color-sale default — the panel renders this row as "Overridden"
@@ -50,28 +49,28 @@ const MOCK_OVERRIDES: Record<string, FoxyCartSettingsOverrides> = {
50
49
  };
51
50
 
52
51
  /**
53
- * Build the FoxyCart cart-appearance view model from the resolved TARGET tenant
52
+ * Build the checkout appearance view model from the resolved TARGET tenant
54
53
  * config. `tenant` is the target's resolved `TenantConfig` (or null when no
55
54
  * tenant claims the id) — resolved by the caller through the resolver seam.
56
55
  */
57
- export function getFoxyCartSettingsViewModel(
56
+ export function getCheckoutSettingsViewModel(
58
57
  envelope: AdminTenantEnvelope,
59
58
  tenant: TenantConfig | null,
60
- ): FoxyCartSettingsViewModel {
59
+ ): CheckoutSettingsViewModel {
61
60
  const { targetTenant } = envelope;
62
- const foxy = tenant?.commerce?.foxy ?? null;
61
+ const checkout = tenant?.commerce?.checkout ?? null;
63
62
 
64
- if (!tenant || !foxy) {
63
+ if (!tenant || !checkout) {
65
64
  return { targetTenant, hasStore: false, fields: [], overrideCount: 0 };
66
65
  }
67
66
 
68
67
  const theme = mergeTheme(referenceTheme, tenant.theme_tokens);
69
- const commerce = resolveFoxyCommerce(foxy);
70
- const defaults = deriveFoxyCartSettings(theme, commerce, {
68
+ const commerce = resolveManagedCheckout(checkout);
69
+ const defaults = deriveCheckoutSettings(theme, commerce, {
71
70
  originBase: `https://${targetTenant}`,
72
71
  });
73
72
  const overrides = MOCK_OVERRIDES[targetTenant] ?? {};
74
- const fields = buildFoxySettingsFields(defaults, overrides);
73
+ const fields = buildCheckoutSettingsFields(defaults, overrides);
75
74
  const overrideCount = fields.filter((f) => f.source === "override").length;
76
75
 
77
76
  return { targetTenant, hasStore: true, fields, overrideCount };
@@ -4,8 +4,9 @@ export { getCustomersViewModel } from "./customers";
4
4
  export { getFulfillmentViewModel } from "./fulfillment";
5
5
  export { getReportingViewModel } from "./reporting";
6
6
  export { getSettingsViewModel } from "./settings";
7
- export { getFoxyCartSettingsViewModel } from "./foxyCartSettings";
8
- export type { FoxyCartSettingsViewModel } from "./foxyCartSettings";
7
+ export { getCheckoutSettingsViewModel } from "./checkoutSettings";
8
+ export type { CheckoutSettingsViewModel } from "./checkoutSettings";
9
+ export { getSubscriptionsViewModel } from "./subscriptions";
9
10
  export { getAiWorkflowsViewModel } from "./aiWorkflows";
10
11
  export { getEvidenceViewModel } from "./evidence";
11
12
  export { SEEDED_TENANTS, tenantProfile } from "./tenants";
@@ -7,7 +7,7 @@ import type { SettingsAuditEvent, SettingsSection, SettingsViewModel } from "../
7
7
  const SECTION_DEFS: Omit<SettingsSection, "riskyChangeReview">[] = [
8
8
  { key: "store", label: "Store", summary: "Store profile, branding, and white-label copy." },
9
9
  { key: "checkout", label: "Checkout", summary: "Checkout assurance and identity/age verification flow." },
10
- { key: "cart_appearance", label: "Cart & checkout appearance", summary: "FoxyCart brand colors and templates, prepopulated from the theme." },
10
+ { key: "cart_appearance", label: "Cart & checkout appearance", summary: "Hosted checkout colors and templates, prepopulated from the theme." },
11
11
  { key: "payments", label: "Payments", summary: "Payment capture timing and gateway configuration." },
12
12
  { key: "shipping", label: "Shipping", summary: "Carriers, rates, and shipping restrictions by region." },
13
13
  { key: "taxes", label: "Taxes", summary: "Excise and sales tax configuration by jurisdiction." },
@@ -0,0 +1,398 @@
1
+ import type {
2
+ ProductPurchaseOption,
3
+ SubscriptionPurchaseOption,
4
+ } from "@tot/public-runtime";
5
+ import {
6
+ DEFAULT_ONE_TIME_PURCHASE_OPTION,
7
+ isSubscriptionFrequency,
8
+ purchaseOptionPrice,
9
+ } from "@tot/public-runtime";
10
+ import type { AdminTenantEnvelope } from "../../envelope";
11
+ import { memoizeByEnvelope } from "../../memoizeByEnvelope";
12
+ import {
13
+ MOCK_ANCHOR_ISO,
14
+ offsetIso,
15
+ randomInt,
16
+ seededRandom,
17
+ } from "../../seed";
18
+ import type { CustomerRow } from "../../viewmodels/customers";
19
+ import type { ProductDetail, ProductRow } from "../../viewmodels/products";
20
+ import type {
21
+ SubscriptionAttentionRow,
22
+ SubscriptionCheckoutField,
23
+ SubscriptionEvidenceEvent,
24
+ SubscriptionProgramDetail,
25
+ SubscriptionProgramReadiness,
26
+ SubscriptionProgramRow,
27
+ SubscriptionRenewalEvent,
28
+ SubscriptionSubscriberRow,
29
+ SubscriptionsViewModel,
30
+ } from "../../viewmodels/subscriptions";
31
+ import { getCustomersViewModel } from "./customers";
32
+ import { getProductsViewModel } from "./products";
33
+
34
+ export interface SubscriptionProductSource {
35
+ productId: string;
36
+ productName: string;
37
+ sellable: boolean;
38
+ blocker: string | null;
39
+ basePrice: number;
40
+ purchaseOptions: ProductPurchaseOption[];
41
+ }
42
+
43
+ const SUBSCRIPTION_TEMPLATES: SubscriptionPurchaseOption[] = [
44
+ {
45
+ id: "monthly",
46
+ kind: "subscription",
47
+ label: "Subscribe monthly",
48
+ description: "Ships every month.",
49
+ savingsLabel: "Save 15%",
50
+ priceAdjustment: { type: "percent_off", value: 15 },
51
+ frequency: "1m",
52
+ },
53
+ {
54
+ id: "every-60-days",
55
+ kind: "subscription",
56
+ label: "Every 60 days",
57
+ description: "A slower refill cadence.",
58
+ savingsLabel: "Save $5",
59
+ priceAdjustment: { type: "amount_off", value: 5 },
60
+ frequency: "60d",
61
+ },
62
+ {
63
+ id: "setup-review",
64
+ kind: "subscription",
65
+ label: "Autopilot setup review",
66
+ description: "Needs a compact checkout interval before launch.",
67
+ savingsLabel: "Save 10%",
68
+ priceAdjustment: { type: "percent_off", value: 10 },
69
+ frequency: "every month",
70
+ },
71
+ ];
72
+
73
+ function frequencyIsValid(option: SubscriptionPurchaseOption): boolean {
74
+ return typeof option.frequency === "string" && isSubscriptionFrequency(option.frequency);
75
+ }
76
+
77
+ function priceAdjustmentLabel(option: SubscriptionPurchaseOption): string | null {
78
+ const adjustment = option.priceAdjustment;
79
+ if (!adjustment) return null;
80
+ if (adjustment.type === "percent_off") return `${adjustment.value}% off`;
81
+ return `$${adjustment.value.toFixed(2)} off`;
82
+ }
83
+
84
+ function readinessFor(
85
+ source: SubscriptionProductSource,
86
+ option: SubscriptionPurchaseOption,
87
+ ): { readiness: SubscriptionProgramReadiness; detail: string } {
88
+ if (!frequencyIsValid(option)) {
89
+ return {
90
+ readiness: "needs_setup",
91
+ detail: "Subscription interval must use a compact checkout frequency.",
92
+ };
93
+ }
94
+ if (!source.sellable) {
95
+ return {
96
+ readiness: "blocked",
97
+ detail: source.blocker ?? "Product readiness is blocking subscription launch.",
98
+ };
99
+ }
100
+ return { readiness: "ready", detail: "Ready for checkout." };
101
+ }
102
+
103
+ export function buildSubscriptionProgramRows(
104
+ sources: SubscriptionProductSource[],
105
+ ): SubscriptionProgramRow[] {
106
+ return sources.flatMap((source) =>
107
+ source.purchaseOptions
108
+ .filter((option): option is SubscriptionPurchaseOption => option.kind === "subscription")
109
+ .map((option) => {
110
+ const readiness = readinessFor(source, option);
111
+ const renewalPrice = frequencyIsValid(option)
112
+ ? purchaseOptionPrice(source.basePrice, option)
113
+ : source.basePrice;
114
+ return {
115
+ id: `sub:${source.productId}:${option.id}`,
116
+ productId: source.productId,
117
+ productName: source.productName,
118
+ optionId: option.id,
119
+ optionLabel: option.label,
120
+ frequency: option.frequency,
121
+ savingsLabel: option.savingsLabel ?? null,
122
+ priceAdjustmentLabel: priceAdjustmentLabel(option),
123
+ readiness: readiness.readiness,
124
+ readinessDetail: readiness.detail,
125
+ subscriberCount: 0,
126
+ upcomingRenewalValue: renewalPrice,
127
+ needsAttentionCount: readiness.readiness === "ready" ? 0 : 1,
128
+ };
129
+ }),
130
+ );
131
+ }
132
+
133
+ function purchaseOptionsForProduct(
134
+ index: number,
135
+ firstSellableIndex: number,
136
+ ): ProductPurchaseOption[] {
137
+ const options: ProductPurchaseOption[] = [DEFAULT_ONE_TIME_PURCHASE_OPTION];
138
+ const template = SUBSCRIPTION_TEMPLATES[index];
139
+ if (template) options.push(template);
140
+ if (index === firstSellableIndex && template?.id !== "monthly") {
141
+ options.push({ ...SUBSCRIPTION_TEMPLATES[0]!, id: "monthly-ready" });
142
+ }
143
+ return options;
144
+ }
145
+
146
+ function sourceFromProduct(
147
+ row: ProductRow,
148
+ detail: ProductDetail,
149
+ index: number,
150
+ firstSellableIndex: number,
151
+ ): SubscriptionProductSource {
152
+ return {
153
+ productId: row.id,
154
+ productName: row.name,
155
+ sellable: row.sellable,
156
+ blocker: row.sellable ? null : `${row.missingExciseCount} catalog readiness gap(s)`,
157
+ basePrice: detail.variants[0]?.price ?? 0,
158
+ purchaseOptions: purchaseOptionsForProduct(index, firstSellableIndex),
159
+ };
160
+ }
161
+
162
+ function checkoutFieldsFor(
163
+ option: SubscriptionPurchaseOption,
164
+ ): SubscriptionCheckoutField[] {
165
+ const fields: SubscriptionCheckoutField[] = [
166
+ { name: "Purchase option", value: option.label },
167
+ { name: "sub_frequency", value: option.frequency },
168
+ ];
169
+ if (option.startDate) fields.push({ name: "sub_startdate", value: option.startDate });
170
+ if (option.endDate) fields.push({ name: "sub_enddate", value: option.endDate });
171
+ if (option.savingsLabel) fields.push({ name: "Savings", value: option.savingsLabel });
172
+ return fields;
173
+ }
174
+
175
+ function setupAttentionFor(
176
+ row: SubscriptionProgramRow,
177
+ source: SubscriptionProductSource,
178
+ ): SubscriptionAttentionRow[] {
179
+ const attention: SubscriptionAttentionRow[] = [];
180
+ if (!isSubscriptionFrequency(row.frequency)) {
181
+ attention.push({
182
+ id: `${row.id}:setup-frequency`,
183
+ kind: "setup_gap",
184
+ programId: row.id,
185
+ productId: row.productId,
186
+ customerId: null,
187
+ label: "Subscription interval needs setup",
188
+ detail: `${row.optionLabel} uses "${row.frequency}". Use a compact interval such as 1m, 60d, or 2w.`,
189
+ action: "Review purchase option",
190
+ tone: "amber",
191
+ blocking: true,
192
+ });
193
+ }
194
+ if (!source.sellable) {
195
+ attention.push({
196
+ id: `${row.id}:product-readiness`,
197
+ kind: "product_blocker",
198
+ programId: row.id,
199
+ productId: row.productId,
200
+ customerId: null,
201
+ label: "Product readiness blocks subscription",
202
+ detail: source.blocker ?? "Resolve product readiness before this can sell.",
203
+ action: "Open product readiness",
204
+ tone: "red",
205
+ blocking: true,
206
+ });
207
+ }
208
+ return attention;
209
+ }
210
+
211
+ function statusForSubscriber(
212
+ programIndex: number,
213
+ subscriberIndex: number,
214
+ ): SubscriptionSubscriberRow["status"] {
215
+ if (programIndex === 0 && subscriberIndex === 0) return "payment_attention";
216
+ if (programIndex === 1 && subscriberIndex === 0) return "verification_hold";
217
+ return "active";
218
+ }
219
+
220
+ function subscribersForProgram(
221
+ targetTenant: string,
222
+ row: SubscriptionProgramRow,
223
+ programIndex: number,
224
+ customers: CustomerRow[],
225
+ rng: () => number,
226
+ ): SubscriptionSubscriberRow[] {
227
+ if (row.readiness === "needs_setup") return [];
228
+ const count = Math.min(customers.length, row.readiness === "ready" ? randomInt(rng, 2, 4) : randomInt(rng, 1, 2));
229
+ return Array.from({ length: count }, (_, index) => {
230
+ const customer = customers[(programIndex + index) % customers.length]!;
231
+ const status = statusForSubscriber(programIndex, index);
232
+ return {
233
+ id: `sub-instance:${targetTenant}:${row.optionId}:${customer.id}`,
234
+ programId: row.id,
235
+ customerId: customer.id,
236
+ customerName: customer.name,
237
+ email: customer.email,
238
+ status,
239
+ verificationState:
240
+ status === "verification_hold" ? "pending" : customer.verificationState,
241
+ paymentState: status === "payment_attention" ? "attention" : "current",
242
+ nextRenewalAt: offsetIso(MOCK_ANCHOR_ISO, randomInt(rng, 1_440, 35_000)),
243
+ renewalValue: row.upcomingRenewalValue,
244
+ };
245
+ });
246
+ }
247
+
248
+ function subscriberAttentionFor(
249
+ row: SubscriptionProgramRow,
250
+ subscriber: SubscriptionSubscriberRow,
251
+ ): SubscriptionAttentionRow | null {
252
+ if (subscriber.status === "payment_attention") {
253
+ return {
254
+ id: `${subscriber.id}:payment`,
255
+ kind: "payment_attention",
256
+ programId: row.id,
257
+ productId: row.productId,
258
+ customerId: subscriber.customerId,
259
+ label: "Payment attention",
260
+ detail: `${subscriber.customerName} needs payment follow-up before the next renewal.`,
261
+ action: "Open customer",
262
+ tone: "amber",
263
+ blocking: true,
264
+ };
265
+ }
266
+ if (subscriber.status === "verification_hold") {
267
+ return {
268
+ id: `${subscriber.id}:verification`,
269
+ kind: "verification_hold",
270
+ programId: row.id,
271
+ productId: row.productId,
272
+ customerId: subscriber.customerId,
273
+ label: "Verification hold",
274
+ detail: `${subscriber.customerName} needs age/identity verification before renewal.`,
275
+ action: "Open customer",
276
+ tone: "red",
277
+ blocking: true,
278
+ };
279
+ }
280
+ return null;
281
+ }
282
+
283
+ function renewalEventsFor(subscribers: SubscriptionSubscriberRow[]): SubscriptionRenewalEvent[] {
284
+ return subscribers.map((subscriber) => ({
285
+ id: `renewal:${subscriber.id}`,
286
+ customerId: subscriber.customerId,
287
+ customerName: subscriber.customerName,
288
+ at: subscriber.nextRenewalAt,
289
+ amount: subscriber.renewalValue,
290
+ state:
291
+ subscriber.status === "active"
292
+ ? "scheduled"
293
+ : subscriber.status === "payment_attention"
294
+ ? "attention"
295
+ : "held",
296
+ }));
297
+ }
298
+
299
+ function evidenceFor(
300
+ row: SubscriptionProgramRow,
301
+ subscribers: SubscriptionSubscriberRow[],
302
+ ): SubscriptionEvidenceEvent[] {
303
+ const events: SubscriptionEvidenceEvent[] = [
304
+ {
305
+ title: "Catalog purchase option detected",
306
+ detail: `${row.optionLabel} is available on ${row.productName}.`,
307
+ at: offsetIso(MOCK_ANCHOR_ISO, -18_000),
308
+ },
309
+ {
310
+ title: "Checkout payload reviewed",
311
+ detail: `Subscription frequency ${row.frequency} is included in the signed cart payload.`,
312
+ at: offsetIso(MOCK_ANCHOR_ISO, -9_000),
313
+ },
314
+ ];
315
+ if (subscribers.length > 0) {
316
+ events.push({
317
+ title: "Subscriber activity imported",
318
+ detail: `${subscribers.length} customer subscription record(s) linked to this program.`,
319
+ at: offsetIso(MOCK_ANCHOR_ISO, -1_800),
320
+ });
321
+ }
322
+ return events;
323
+ }
324
+
325
+ function computeSubscriptionsViewModel(envelope: AdminTenantEnvelope): SubscriptionsViewModel {
326
+ const { targetTenant } = envelope;
327
+ const rng = seededRandom(`subscriptions:${targetTenant}`);
328
+ const products = getProductsViewModel(envelope);
329
+ const customers = getCustomersViewModel(envelope);
330
+ const firstSellableIndex = products.rows.findIndex((row) => row.sellable);
331
+ const sources = products.rows.map((row, index) =>
332
+ sourceFromProduct(row, products.detailsById[row.id]!, index, firstSellableIndex),
333
+ );
334
+ const baseRows = buildSubscriptionProgramRows(sources);
335
+ const sourceByProgramId = new Map(
336
+ baseRows.map((row) => [
337
+ row.id,
338
+ sources.find((source) => source.productId === row.productId)!,
339
+ ]),
340
+ );
341
+ const optionByProgramId = new Map<string, SubscriptionPurchaseOption>(
342
+ sources.flatMap((source) =>
343
+ source.purchaseOptions
344
+ .filter((option): option is SubscriptionPurchaseOption => option.kind === "subscription")
345
+ .map((option) => [`sub:${source.productId}:${option.id}`, option] as const),
346
+ ),
347
+ );
348
+
349
+ const programs: SubscriptionProgramRow[] = [];
350
+ const detailsById: Record<string, SubscriptionProgramDetail> = {};
351
+ const allAttention: SubscriptionAttentionRow[] = [];
352
+
353
+ baseRows.forEach((baseRow, index) => {
354
+ const source = sourceByProgramId.get(baseRow.id)!;
355
+ const option = optionByProgramId.get(baseRow.id)!;
356
+ const subscribers = subscribersForProgram(targetTenant, baseRow, index, customers.directory, rng);
357
+ const setupAttention = setupAttentionFor(baseRow, source);
358
+ const subscriberAttention = subscribers
359
+ .map((subscriber) => subscriberAttentionFor(baseRow, subscriber))
360
+ .filter((row): row is SubscriptionAttentionRow => row !== null);
361
+ const attention = [...setupAttention, ...subscriberAttention];
362
+ const upcomingRenewals = renewalEventsFor(subscribers);
363
+ const upcomingRenewalValue = upcomingRenewals
364
+ .filter((event) => event.state === "scheduled")
365
+ .reduce((sum, event) => sum + event.amount, 0);
366
+ const row: SubscriptionProgramRow = {
367
+ ...baseRow,
368
+ subscriberCount: subscribers.length,
369
+ upcomingRenewalValue,
370
+ needsAttentionCount: attention.length,
371
+ };
372
+ programs.push(row);
373
+ allAttention.push(...attention);
374
+ detailsById[row.id] = {
375
+ ...row,
376
+ checkoutFields: checkoutFieldsFor(option),
377
+ subscribers,
378
+ upcomingRenewals,
379
+ evidenceTimeline: evidenceFor(row, subscribers),
380
+ attention,
381
+ };
382
+ });
383
+
384
+ return {
385
+ targetTenant,
386
+ summary: {
387
+ subscriptionProducts: programs.length,
388
+ activeSubscribers: programs.reduce((sum, row) => sum + row.subscriberCount, 0),
389
+ upcomingRenewalValue: programs.reduce((sum, row) => sum + row.upcomingRenewalValue, 0),
390
+ needsAttention: allAttention.length,
391
+ },
392
+ programs,
393
+ detailsById,
394
+ attention: allAttention.sort((a, b) => Number(b.blocking) - Number(a.blocking)),
395
+ };
396
+ }
397
+
398
+ export const getSubscriptionsViewModel = memoizeByEnvelope(computeSubscriptionsViewModel);