@shoppexio/storefront-react 0.3.0 → 0.5.0

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 (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/account.css +26 -8
  3. package/dist/account.js +3217 -1001
  4. package/dist/account.js.map +1 -1
  5. package/dist/index.d.ts +188 -14
  6. package/dist/index.js +43 -20
  7. package/dist/index.js.map +1 -1
  8. package/eject/account/account-downloads.ts +89 -1
  9. package/eject/account/account-payment-method.ts +1 -0
  10. package/eject/account/account-referrals.ts +70 -1
  11. package/eject/account/account-reseller.ts +6 -140
  12. package/eject/account/account-subscriptions.ts +158 -6
  13. package/eject/account/buyer-invoice-status.ts +100 -0
  14. package/eject/account/downloads.tsx +118 -17
  15. package/eject/account/hosted-page.ts +45 -0
  16. package/eject/account/messages/ar.json +150 -39
  17. package/eject/account/messages/de.json +150 -39
  18. package/eject/account/messages/en.json +150 -39
  19. package/eject/account/messages/es.json +150 -39
  20. package/eject/account/messages/fi.json +150 -39
  21. package/eject/account/messages/fr.json +150 -39
  22. package/eject/account/messages/hi.json +150 -39
  23. package/eject/account/messages/it.json +150 -39
  24. package/eject/account/messages/lt.json +150 -39
  25. package/eject/account/messages/nl.json +150 -39
  26. package/eject/account/messages/pl.json +150 -39
  27. package/eject/account/messages/pt.json +150 -39
  28. package/eject/account/messages/ru.json +150 -39
  29. package/eject/account/messages/tr.json +150 -39
  30. package/eject/account/messages/uk.json +150 -39
  31. package/eject/account/messages/zh.json +150 -39
  32. package/eject/account/orders.tsx +203 -41
  33. package/eject/account/overview.tsx +6 -0
  34. package/eject/account/referrals.tsx +47 -5
  35. package/eject/account/reseller.tsx +62 -308
  36. package/eject/account/rewards.tsx +5 -0
  37. package/eject/account/shared.tsx +60 -4
  38. package/eject/account/subscriptions.tsx +387 -50
  39. package/package.json +4 -4
@@ -1,4 +1,5 @@
1
- import type { SdkCustomerDeliverable, SdkCustomerLicense } from '@shoppexio/storefront-react';
1
+ import type { SdkCustomerDeliverable, SdkCustomerFileAccess, SdkCustomerLicense } from '@shoppexio/storefront-react';
2
+ import { getActiveLocale, t } from './i18n';
2
3
 
3
4
  /**
4
5
  * The downloads panel's pure half.
@@ -116,3 +117,90 @@ export function offersRenewal(license: SdkCustomerLicense): boolean {
116
117
  return license.renewable && license.expires_at !== null;
117
118
  }
118
119
 
120
+
121
+ /** What one file still allows, as the row says it (Design Batch 2, F1). */
122
+ export interface FileAccessCopy {
123
+ /** What is left and until when, or why it stopped. Null: nothing to say (unlimited, no end date). */
124
+ text: string | null;
125
+ /** The file no longer downloads, so the row offers no Download. */
126
+ refused: boolean;
127
+ /** The buyer can ask the shop to open it again. A refund closed it for a reason the shop already decided. */
128
+ canAsk: boolean;
129
+ }
130
+
131
+ /**
132
+ * A file's end date, as a day in the SHOP's calendar.
133
+ *
134
+ * The producer sends an instant and the shop's IANA zone. An end at 23:30 in
135
+ * Berlin is already the next day in UTC and still the same day in New York, so
136
+ * formatting in the viewer's zone would name a day the shop never set.
137
+ */
138
+ export function fileAccessDate(endsAt: string, timeZone: string): string {
139
+ const date = new Date(endsAt);
140
+ if (Number.isNaN(date.getTime())) return endsAt;
141
+ return new Intl.DateTimeFormat(getActiveLocale(), {
142
+ year: 'numeric',
143
+ month: 'short',
144
+ day: 'numeric',
145
+ timeZone,
146
+ }).format(date);
147
+ }
148
+
149
+ /**
150
+ * The same sentences as the hosted portal's `describeFileAccess`. An open file
151
+ * names only the parts the producer sent: no limit and no end date says
152
+ * nothing at all rather than "unlimited".
153
+ */
154
+ export function describeFileAccess(access: SdkCustomerFileAccess): FileAccessCopy {
155
+ const until = access.ends_at ? fileAccessDate(access.ends_at, access.time_zone) : null;
156
+ switch (access.state) {
157
+ case 'open': {
158
+ const parts: string[] = [];
159
+ if (access.downloads_left !== null) {
160
+ parts.push(t(
161
+ access.downloads_left === 1 ? 'downloads.accessLeftOne' : 'downloads.accessLeftOther',
162
+ { count: access.downloads_left },
163
+ ));
164
+ }
165
+ if (until) parts.push(t('downloads.accessUntil', { date: until }));
166
+ return { text: parts.length > 0 ? parts.join(' · ') : null, refused: false, canAsk: false };
167
+ }
168
+ case 'limit_reached':
169
+ return {
170
+ text: access.download_limit === null
171
+ ? t('downloads.accessLimitReached')
172
+ : t(
173
+ access.download_limit === 1 ? 'downloads.accessLimitReachedOne' : 'downloads.accessLimitReachedOther',
174
+ { count: access.download_limit },
175
+ ),
176
+ refused: true,
177
+ canAsk: true,
178
+ };
179
+ case 'expired':
180
+ return {
181
+ text: until ? t('downloads.accessExpired', { date: until }) : t('downloads.accessExpiredUndated'),
182
+ refused: true,
183
+ canAsk: true,
184
+ };
185
+ case 'revoked':
186
+ return { text: t('downloads.accessRevoked'), refused: true, canAsk: true };
187
+ case 'refunded':
188
+ return { text: t('downloads.accessRefunded'), refused: true, canAsk: false };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The ticket "Ask for access" opens: on the order, naming the file and why it
194
+ * stopped, so the shop can extend, reset or restore it from the order page.
195
+ */
196
+ export function accessRequestTicket(
197
+ file: Pick<SdkCustomerDeliverable, 'label' | 'product_title' | 'invoice_uniqid'>,
198
+ reason: string | null,
199
+ ): { title: string; message: string; invoice_id: string } {
200
+ const message = t('downloads.accessRequestMessage', { file: file.label, product: file.product_title });
201
+ return {
202
+ title: t('downloads.accessRequestTitle', { file: file.label }).slice(0, 120),
203
+ message: reason ? `${message} ${t('downloads.accessRequestReason', { reason })}` : message,
204
+ invoice_id: file.invoice_uniqid,
205
+ };
206
+ }
@@ -15,6 +15,7 @@
15
15
  const GATEWAY_LABELS: Record<string, string> = {
16
16
  STRIPE: 'Stripe',
17
17
  PAYPAL: 'PayPal',
18
+ PAYPAL_FF: 'PayPal F&F',
18
19
  CASHAPP: 'Cash App',
19
20
  SQUARE: 'Square',
20
21
  BITCOIN: 'Bitcoin',
@@ -1,4 +1,4 @@
1
- import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
1
+ import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateRates, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
2
2
 
3
3
  /**
4
4
  * The referrals panel's pure half.
@@ -239,3 +239,72 @@ export function referralLinkInput(draft: ReferralLinkDraft): ReferralLinkInputRe
239
239
  },
240
240
  };
241
241
  }
242
+
243
+ /** A catalog key and its values, translated by the panel. */
244
+ export interface ReferralMessage {
245
+ key: string;
246
+ params?: Record<string, string>;
247
+ }
248
+
249
+ export interface ReferralRateRow {
250
+ id: 'rate' | 'order' | 'binding';
251
+ title: ReferralMessage;
252
+ detail: ReferralMessage | null;
253
+ }
254
+
255
+ /**
256
+ * "Your rates" (Design Batch 3 F2), the same rows as the Easy-lane account
257
+ * block: the rate and the products that earn nothing, the cap and minimum per
258
+ * order, and how long a referred buyer stays. A part shows only when the shop
259
+ * set it. One rate per link was not true once products, a cap and a minimum
260
+ * could pay less.
261
+ */
262
+ export function referralRateRows(
263
+ rates: SdkCustomerAffiliateRates,
264
+ format: {
265
+ money: (usd: string) => string;
266
+ /** "A, B and C" in the reader's language. */
267
+ list: (names: string[]) => string;
268
+ /** "2 more", for the No Commission products past the three named. */
269
+ more: (count: number) => string;
270
+ },
271
+ ): ReferralRateRow[] {
272
+ const percent = String(rates.percent);
273
+ const more = rates.disabled_product_count - rates.disabled_products.length;
274
+ const names = more > 0 ? [...rates.disabled_products, format.more(more)] : rates.disabled_products;
275
+ const disabled: ReferralMessage | null = rates.disabled_product_count === 0
276
+ ? null
277
+ : rates.disabled_product_count === 1
278
+ ? { key: 'referrals.ratesProductEarnsNothing', params: { product: format.list(names) } }
279
+ : { key: 'referrals.ratesProductsEarnNothing', params: { products: format.list(names) } };
280
+ // A product with its own rate pays that instead of this one, so "every product" would overpromise.
281
+ const customRate: ReferralMessage | null = rates.custom_rate_product_count === 0 ? null : { key: 'referrals.ratesCustomRateProducts' };
282
+ const rows: ReferralRateRow[] = [{
283
+ id: 'rate',
284
+ title: { key: disabled || customRate ? 'referrals.ratesMostProducts' : 'referrals.ratesEveryProduct', params: { percent } },
285
+ detail: disabled ?? customRate,
286
+ }];
287
+ const minimum: ReferralMessage | null = rates.minimum_order_usd === null
288
+ ? null
289
+ : { key: 'referrals.ratesMinimum', params: { amount: format.money(rates.minimum_order_usd) } };
290
+ if (rates.cap_usd !== null) {
291
+ rows.push({ id: 'order', title: { key: 'referrals.ratesCap', params: { amount: format.money(rates.cap_usd) } }, detail: minimum });
292
+ } else if (minimum) {
293
+ rows.push({ id: 'order', title: minimum, detail: null });
294
+ }
295
+ if (rates.binding_mode !== 'PER_ORDER') {
296
+ rows.push({
297
+ id: 'binding',
298
+ title: rates.binding_days === null
299
+ ? { key: 'referrals.ratesBindingForever' }
300
+ : { key: 'referrals.ratesBindingDays', params: { days: String(rates.binding_days) } },
301
+ detail: { key: 'referrals.ratesBindingDetail' },
302
+ });
303
+ }
304
+ return rows;
305
+ }
306
+
307
+ /** A link names its rate only when it earns another one than "Your rates" says. */
308
+ export function linkCommissionDiffers(link: SdkCustomerAffiliateLink, rates: SdkCustomerAffiliateRates | null): boolean {
309
+ return rates === null || link.effective_commission_percent !== rates.percent;
310
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure wire narrowing and basket quote helpers for the wholesale account tab.
2
+ * Pure wire narrowing for the wholesale account tab.
3
3
  *
4
4
  * The shapes below follow the mapper functions in
5
5
  * `apps/backend-elysia/src/routes/v1/customer-reseller.ts` exactly. They do not
@@ -38,6 +38,8 @@ export interface ResellerProgramState {
38
38
  enrollment_mode: ResellerEnrollmentMode;
39
39
  application_message: string | null;
40
40
  require_application_note: boolean;
41
+ /** The shop hands out delivery URLs; the "Delivery URL" link shows only then. */
42
+ delivery_url_enabled: boolean;
41
43
  reseller: ResellerRelationship | null;
42
44
  }
43
45
 
@@ -45,7 +47,6 @@ export interface ResellerCatalogVariant {
45
47
  variant_id: string;
46
48
  title: string;
47
49
  stock: number;
48
- supplier_backed: boolean;
49
50
  orderable: boolean;
50
51
  base_price: string;
51
52
  unit_price: string;
@@ -59,7 +60,6 @@ export interface ResellerCatalogItem {
59
60
  title: string;
60
61
  type: string;
61
62
  stock: number;
62
- supplier_backed: boolean;
63
63
  orderable: boolean;
64
64
  volume_discount_enabled: boolean;
65
65
  base_price: string;
@@ -82,13 +82,7 @@ export interface ResellerCatalogPayload {
82
82
  catalog: ResellerCatalog;
83
83
  }
84
84
 
85
- export interface ResellerOrderItemInput {
86
- product_id: string;
87
- variant_id?: string;
88
- quantity: number;
89
- }
90
-
91
- /** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions. */
85
+ /** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions; an order line is read against them. */
92
86
  export const RESELLER_QUANTITY_MIN_UNITS = 1;
93
87
  export const RESELLER_QUANTITY_MAX_UNITS = 100_000;
94
88
 
@@ -98,31 +92,6 @@ export function isValidResellerQuantity(value: number): boolean {
98
92
  && value <= RESELLER_QUANTITY_MAX_UNITS;
99
93
  }
100
94
 
101
- export interface ResellerQuoteItem {
102
- product_id: string;
103
- product_title: string;
104
- product_type: string;
105
- variant_id: string | null;
106
- variant_title: string | null;
107
- quantity: number;
108
- base_price: string;
109
- discount_percent: number;
110
- unit_price: string;
111
- line_total: string;
112
- applied_override: ResellerAppliedOverride;
113
- volume_discount_applied: string;
114
- }
115
-
116
- export interface ResellerQuote {
117
- currency: string;
118
- tier: { id: string; name: string; discount_percent: number };
119
- items: ResellerQuoteItem[];
120
- subtotal: string;
121
- volume_discount_total: string;
122
- total: string;
123
- savings: string;
124
- }
125
-
126
95
  export interface ResellerOrderListItem {
127
96
  uniqid: string;
128
97
  status: string;
@@ -287,6 +256,7 @@ export function narrowResellerProgram(
287
256
  !isEnrollmentMode(payload.enrollment_mode) ||
288
257
  !isNullableString(payload.application_message) ||
289
258
  typeof payload.require_application_note !== "boolean" ||
259
+ typeof payload.delivery_url_enabled !== "boolean" ||
290
260
  reseller === undefined
291
261
  ) {
292
262
  return { ok: false };
@@ -298,6 +268,7 @@ export function narrowResellerProgram(
298
268
  enrollment_mode: payload.enrollment_mode,
299
269
  application_message: payload.application_message,
300
270
  require_application_note: payload.require_application_note,
271
+ delivery_url_enabled: payload.delivery_url_enabled,
301
272
  reseller,
302
273
  },
303
274
  };
@@ -318,7 +289,6 @@ function narrowCatalogVariant(value: unknown): ResellerCatalogVariant | null {
318
289
  typeof value.variant_id !== "string" ||
319
290
  typeof value.title !== "string" ||
320
291
  !isFiniteNumber(value.stock) ||
321
- typeof value.supplier_backed !== "boolean" ||
322
292
  typeof value.orderable !== "boolean" ||
323
293
  typeof value.base_price !== "string" ||
324
294
  typeof value.unit_price !== "string" ||
@@ -343,7 +313,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
343
313
  typeof value.title !== "string" ||
344
314
  typeof value.type !== "string" ||
345
315
  !isFiniteNumber(value.stock) ||
346
- typeof value.supplier_backed !== "boolean" ||
347
316
  typeof value.orderable !== "boolean" ||
348
317
  typeof value.volume_discount_enabled !== "boolean" ||
349
318
  typeof value.base_price !== "string" ||
@@ -358,7 +327,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
358
327
  title: value.title,
359
328
  type: value.type,
360
329
  stock: value.stock,
361
- supplier_backed: value.supplier_backed,
362
330
  orderable: value.orderable,
363
331
  volume_discount_enabled: value.volume_discount_enabled,
364
332
  base_price: value.base_price,
@@ -412,69 +380,6 @@ export function narrowResellerCatalog(
412
380
  };
413
381
  }
414
382
 
415
- function narrowQuoteItem(value: unknown): ResellerQuoteItem | null {
416
- if (!isRecord(value)) return null;
417
- if (
418
- typeof value.product_id !== "string" ||
419
- typeof value.product_title !== "string" ||
420
- typeof value.product_type !== "string" ||
421
- !isNullableString(value.variant_id) ||
422
- !isNullableString(value.variant_title) ||
423
- !isValidResellerQuantity(value.quantity as number) ||
424
- typeof value.base_price !== "string" ||
425
- !isFiniteNumber(value.discount_percent) ||
426
- typeof value.unit_price !== "string" ||
427
- typeof value.line_total !== "string" ||
428
- !isAppliedOverride(value.applied_override) ||
429
- typeof value.volume_discount_applied !== "string"
430
- )
431
- return null;
432
- return value as unknown as ResellerQuoteItem;
433
- }
434
-
435
- /** `POST /reseller/orders/quote`, written against mapQuote. */
436
- export function narrowResellerQuote(
437
- payload: unknown,
438
- ): NarrowResult<ResellerQuote> {
439
- if (!isRecord(payload) || !isRecord(payload.quote)) return { ok: false };
440
- const raw = payload.quote;
441
- const tier = narrowTier(raw.tier);
442
- if (
443
- !tier ||
444
- typeof tier.name !== "string" ||
445
- typeof tier.discount_percent !== "number" ||
446
- typeof raw.currency !== "string" ||
447
- !Array.isArray(raw.items) ||
448
- typeof raw.subtotal !== "string" ||
449
- typeof raw.volume_discount_total !== "string" ||
450
- typeof raw.total !== "string" ||
451
- typeof raw.savings !== "string"
452
- )
453
- return { ok: false };
454
- const items: ResellerQuoteItem[] = [];
455
- for (const entry of raw.items) {
456
- const item = narrowQuoteItem(entry);
457
- if (!item) return { ok: false };
458
- items.push(item);
459
- }
460
- return {
461
- ok: true,
462
- data: {
463
- currency: raw.currency,
464
- tier: {
465
- id: tier.id,
466
- name: tier.name,
467
- discount_percent: tier.discount_percent,
468
- },
469
- items,
470
- subtotal: raw.subtotal,
471
- volume_discount_total: raw.volume_discount_total,
472
- total: raw.total,
473
- savings: raw.savings,
474
- },
475
- };
476
- }
477
-
478
383
  function narrowOrderListItem(value: unknown): ResellerOrderListItem | null {
479
384
  if (!isRecord(value)) return null;
480
385
  if (
@@ -627,35 +532,6 @@ export function narrowResellerApiKeys(
627
532
  return { ok: true, data: keys };
628
533
  }
629
534
 
630
- /** Stable identity of one basket version. Order is normalized for comparison. */
631
- export function resellerBasketKey(
632
- items: readonly ResellerOrderItemInput[],
633
- ): string {
634
- for (const item of items) {
635
- if (!isValidResellerQuantity(item.quantity)) {
636
- throw new RangeError(
637
- `Wholesale quantity must be a whole number from ${RESELLER_QUANTITY_MIN_UNITS} to ${RESELLER_QUANTITY_MAX_UNITS} units; received ${String(item.quantity)}.`,
638
- );
639
- }
640
- }
641
-
642
- return JSON.stringify(
643
- [...items]
644
- .map((item) => ({
645
- product_id: item.product_id,
646
- variant_id: item.variant_id ?? null,
647
- quantity: item.quantity,
648
- }))
649
- .sort((left, right) => {
650
- const product = left.product_id.localeCompare(right.product_id);
651
- return (
652
- product ||
653
- (left.variant_id ?? "").localeCompare(right.variant_id ?? "")
654
- );
655
- }),
656
- );
657
- }
658
-
659
535
  /**
660
536
  * The one way into the program this buyer actually has.
661
537
  *
@@ -673,13 +549,3 @@ export function resellerEntryMode(
673
549
  ? "MANUAL"
674
550
  : program.enrollment_mode;
675
551
  }
676
-
677
- /** A quote is displayed only for the basket it priced. */
678
- export function quoteMatchesBasket(
679
- quotedBasketKey: string | null,
680
- items: readonly ResellerOrderItemInput[],
681
- ): boolean {
682
- return (
683
- quotedBasketKey !== null && quotedBasketKey === resellerBasketKey(items)
684
- );
685
- }
@@ -1,4 +1,4 @@
1
- import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
1
+ import type { SdkCustomerOpenRenewal, SdkCustomerPauseOptions, SdkCustomerSubscription } from '@shoppexio/storefront-react';
2
2
 
3
3
  /**
4
4
  * The subscriptions panel's pure half: which date matters, how the plan bills,
@@ -14,17 +14,38 @@ import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
14
14
  * renewal it will never take.
15
15
  */
16
16
  export type SubscriptionTiming =
17
+ | { kind: 'ended'; date: string }
17
18
  | { kind: 'canceled'; date: string }
18
19
  | { kind: 'ends'; date: string }
20
+ /** A renewal on hold or past its date: the deadline to pay it, not a renewal date already gone. */
21
+ | { kind: 'payBy'; date: string }
22
+ | { kind: 'pauses'; date: string }
23
+ | { kind: 'resumes'; date: string }
24
+ /** Paused by the merchant or the API without a resume date. */
25
+ | { kind: 'pausedOpenEnded'; date: string }
19
26
  | { kind: 'trial'; date: string }
20
27
  | { kind: 'renews'; date: string };
21
28
 
22
29
  export function subscriptionTiming(subscription: SdkCustomerSubscription): SubscriptionTiming {
30
+ if (endedAfterHold(subscription) && subscription.canceled_at) {
31
+ return { kind: 'ended', date: subscription.canceled_at };
32
+ }
23
33
  if (subscription.status.toUpperCase() === 'CANCELED' && subscription.canceled_at) {
24
34
  return { kind: 'canceled', date: subscription.canceled_at };
25
35
  }
26
- if (subscription.cancel_at_period_end) {
27
- return { kind: 'ends', date: subscription.current_period_end };
36
+ const renewal = openRenewalState(subscription);
37
+ if (subscription.open_renewal && (renewal === 'held' || renewal === 'due')) {
38
+ return { kind: 'payBy', date: subscription.open_renewal.pay_by };
39
+ }
40
+ // The producer's one "set to end" fact: cancelled at the period end, or a voided renewal.
41
+ if (subscription.ends_at) {
42
+ return { kind: 'ends', date: subscription.ends_at };
43
+ }
44
+ // A paused or pausing plan bills nothing at its period end; it bills again when it resumes.
45
+ const pause = subscription.pause;
46
+ if (pause?.state === 'scheduled') return { kind: 'pauses', date: pause.starts_at };
47
+ if (pause?.state === 'paused') {
48
+ return pause.resume_at === null ? { kind: 'pausedOpenEnded', date: '' } : { kind: 'resumes', date: pause.resume_at };
28
49
  }
29
50
  if (subscription.status.toUpperCase() === 'TRIALING' && subscription.trial_end) {
30
51
  return { kind: 'trial', date: subscription.trial_end };
@@ -34,7 +55,24 @@ export function subscriptionTiming(subscription: SdkCustomerSubscription): Subsc
34
55
 
35
56
  /** The catalog key for a timing, so the panel never spells one itself. */
36
57
  export function subscriptionTimingKey(timing: SubscriptionTiming): string {
37
- return `subscriptions.${timing.kind}`;
58
+ return timing.kind === 'ended' ? 'subscriptions.endedOn' : `subscriptions.${timing.kind}`;
59
+ }
60
+
61
+ /**
62
+ * A subscription that ended with its renewal still unpaid (the hold ran out, or the
63
+ * shop ended it during the hold): CANCELED with the `hold_ends_at` the hold left on
64
+ * the row. `ranOut` is read from the two producer timestamps, never from the date today.
65
+ */
66
+ export function endedAfterHold(subscription: SdkCustomerSubscription): { ranOut: boolean } | null {
67
+ if (subscription.status.toUpperCase() !== 'CANCELED' || !subscription.hold_ends_at || !subscription.canceled_at) return null;
68
+ const canceledAt = producerTimestamp(subscription.canceled_at);
69
+ const holdEndsAt = producerTimestamp(subscription.hold_ends_at);
70
+ return { ranOut: canceledAt !== null && holdEndsAt !== null && canceledAt >= holdEndsAt };
71
+ }
72
+
73
+ /** The catalog key of the ended note's sentence. */
74
+ export function endedNoteKey(ended: { ranOut: boolean }): string {
75
+ return ended.ranOut ? 'subscriptions.endedHoldRanOut' : 'subscriptions.endedShopEnded';
38
76
  }
39
77
 
40
78
  const KNOWN_INTERVALS: ReadonlySet<string> = new Set(['day', 'week', 'month', 'year']);
@@ -82,6 +120,44 @@ export interface BillingHistoryInvoice {
82
120
  currency: string;
83
121
  status: string;
84
122
  created_at: string;
123
+ /** What the invoice billed: the order that started the plan, a renewal, or a plan change. */
124
+ kind: 'first_order' | 'renewal' | 'plan_change';
125
+ /** The plan change it paid for; null on a renewal or a first order. Days are null when the producer did not count them. */
126
+ plan_change: { from_plan_name: string; to_plan_name: string; days_left: number | null; days_total: number | null } | null;
127
+ }
128
+
129
+ const BILLING_KINDS: ReadonlyArray<BillingHistoryInvoice['kind']> = ['first_order', 'renewal', 'plan_change'];
130
+
131
+ function readCount(record: Record<string, unknown>, key: string): number | null | undefined {
132
+ const value = record[key];
133
+ if (value === null) return null;
134
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
135
+ }
136
+
137
+ function readPlanChange(value: unknown): BillingHistoryInvoice['plan_change'] | undefined {
138
+ if (value === null) return null;
139
+ if (!isRecord(value)) return undefined;
140
+ const from = readString(value, 'from_plan_name');
141
+ const to = readString(value, 'to_plan_name');
142
+ const daysLeft = readCount(value, 'days_left');
143
+ const daysTotal = readCount(value, 'days_total');
144
+ if (from === null || to === null || daysLeft === undefined || daysTotal === undefined) return undefined;
145
+ return { from_plan_name: from, to_plan_name: to, days_left: daysLeft, days_total: daysTotal };
146
+ }
147
+
148
+ /** What a billing history row billed (Design Batch 9, C3), as a catalog key: "Renewal", "First order", "Plan change, Basic to Pro". */
149
+ export function billingEntryTitle(invoice: BillingHistoryInvoice): { key: string; params?: { from: string; to: string } } {
150
+ if (invoice.plan_change) {
151
+ return { key: 'subscriptions.historyPlanChange', params: { from: invoice.plan_change.from_plan_name, to: invoice.plan_change.to_plan_name } };
152
+ }
153
+ return { key: invoice.kind === 'first_order' ? 'subscriptions.historyFirstOrder' : 'subscriptions.historyRenewal' };
154
+ }
155
+
156
+ /** "for 12 of 30 days" beside a plan change's date, or null when there is nothing to count. */
157
+ export function billingEntryDays(invoice: BillingHistoryInvoice): { key: string; params: { days: number; total: number } } | null {
158
+ const change = invoice.plan_change;
159
+ if (!change || change.days_left === null || change.days_total === null) return null;
160
+ return { key: 'subscriptions.historyDays', params: { days: change.days_left, total: change.days_total } };
85
161
  }
86
162
 
87
163
  export type BillingHistoryResult =
@@ -118,13 +194,89 @@ export function narrowBillingHistory(payload: unknown): BillingHistoryResult {
118
194
  const currency = readString(entry, 'currency');
119
195
  const status = readString(entry, 'status');
120
196
  const createdAt = readString(entry, 'created_at');
121
- if (uniqid === null || total === null || currency === null || status === null || createdAt === null) {
197
+ // A kind this build does not know fails the read: a row that guesses what it billed could misname a charge.
198
+ const kind = BILLING_KINDS.find((known) => known === entry.kind);
199
+ const planChange = readPlanChange(entry.plan_change);
200
+ if (uniqid === null || total === null || currency === null || status === null || createdAt === null || !kind || planChange === undefined) {
122
201
  return { ok: false };
123
202
  }
124
203
 
125
- invoices.push({ uniqid, total, currency, status, created_at: createdAt });
204
+ invoices.push({ uniqid, total, currency, status, created_at: createdAt, kind, plan_change: planChange });
126
205
  }
127
206
 
128
207
  return { ok: true, invoices };
129
208
  }
130
209
 
210
+ /** The length the pause dialog opens with: one month when the shop offers it, else the longest it offers. */
211
+ export function defaultPauseLength(options: SdkCustomerPauseOptions): SdkCustomerPauseOptions['lengths'][number]['days'] | null {
212
+ if (options.lengths.some((length) => length.days === 30)) return 30;
213
+ return options.lengths[options.lengths.length - 1]?.days ?? null;
214
+ }
215
+
216
+ /** "You can pause {n} more times…" as a catalog key and its count. */
217
+ export function pausesLeftMessage(left: number): { key: string; params: { count: number } } {
218
+ const key = left <= 0 ? 'subscriptions.pauseLeftNone' : left === 1 ? 'subscriptions.pauseLeftOne' : 'subscriptions.pauseLeftMany';
219
+ return { key, params: { count: left } };
220
+ }
221
+
222
+ /**
223
+ * Whether "Resume now" bills a new period today (`resumedPeriodEnd` in subscriptionPause.ts):
224
+ * a pause from the period end left no paid days, a "right away" pause gives the days left back.
225
+ */
226
+ export function resumeBillsToday(subscription: SdkCustomerSubscription): boolean {
227
+ const pause = subscription.pause;
228
+ if (pause?.state !== 'paused') return false;
229
+ const periodEnd = producerTimestamp(subscription.current_period_end);
230
+ const startedAt = producerTimestamp(pause.starts_at);
231
+ return periodEnd !== null && startedAt !== null && periodEnd <= startedAt;
232
+ }
233
+
234
+ /**
235
+ * Where the open renewal stands, read only from the producer's `open_renewal`
236
+ * and `hold_ends_at`, never from guessed dates:
237
+ * - `held`: on hold (PAST_DUE with a hold end), access is off until the buyer pays;
238
+ * - `ready`: invoiced ahead, the renewal date is still to come;
239
+ * - `due`: the renewal date passed and the time to pay runs.
240
+ * Null when nothing is open, and on an ended subscription.
241
+ */
242
+ export type OpenRenewalState = 'held' | 'due' | 'ready';
243
+
244
+ const NOTE_INTERVALS: Readonly<Record<string, string>> = { DAY: 'Day', WEEK: 'Week', MONTH: 'Month', YEAR: 'Year' };
245
+
246
+ /**
247
+ * The unit inside a renewal note ("a new month"), from the plan the renewal invoice bills
248
+ * (a scheduled interval change bills its target before the subscription's own plan moves):
249
+ * only a plan that bills once per unit names it. Several units or an unknown unit read as
250
+ * the generic billing period, never as a unit the producer did not send.
251
+ */
252
+ export function noteIntervalKey(open: SdkCustomerOpenRenewal, note: 'hold' | 'ready'): string {
253
+ const unit = open.interval_count === 1 ? NOTE_INTERVALS[open.interval.trim().toUpperCase()] : undefined;
254
+ return `subscriptions.${note}Interval${unit ?? 'Other'}`;
255
+ }
256
+
257
+ export function openRenewalState(subscription: SdkCustomerSubscription, now: number = Date.now()): OpenRenewalState | null {
258
+ const status = subscription.status.toUpperCase();
259
+ if (!subscription.open_renewal || status === 'CANCELED') return null;
260
+ if (status === 'PAST_DUE' && subscription.hold_ends_at) return 'held';
261
+ const renewsAt = producerTimestamp(subscription.current_period_end);
262
+ return renewsAt !== null && renewsAt > now ? 'ready' : 'due';
263
+ }
264
+
265
+ /**
266
+ * The renewal date for display: naive PostgreSQL strings ("2026-08-10 00:00:00.000")
267
+ * are UTC, not the buyer's local zone. An unreadable value comes back as it came.
268
+ */
269
+ export function renewalDate(value: string): Date | string {
270
+ const parsed = producerTimestamp(value);
271
+ return parsed === null ? value : new Date(parsed);
272
+ }
273
+
274
+ function producerTimestamp(value: string): number | null {
275
+ const trimmed = value.trim();
276
+ if (!trimmed) return null;
277
+ const hasTimezone = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(trimmed);
278
+ const hasTimePart = trimmed.includes(' ') || trimmed.includes('T');
279
+ const normalized = hasTimezone || !hasTimePart ? trimmed : `${trimmed.replace(' ', 'T')}Z`;
280
+ const parsed = Date.parse(normalized);
281
+ return Number.isFinite(parsed) ? parsed : null;
282
+ }