@spree/dashboard-core 1.0.0-rc.1 → 1.0.0-rc.3

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 (58) hide show
  1. package/dist/vite/discover.js +9 -14
  2. package/package.json +3 -5
  3. package/src/api-client.ts +22 -34
  4. package/src/components/address-form-dialog.test.ts +4 -4
  5. package/src/components/address-form-dialog.tsx +10 -10
  6. package/src/components/app-shell.tsx +12 -1
  7. package/src/components/app-sidebar.tsx +18 -2
  8. package/src/components/country-combobox.tsx +52 -29
  9. package/src/components/currency-select.tsx +16 -10
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/mobile-top-bar.tsx +6 -0
  13. package/src/components/money.tsx +23 -0
  14. package/src/components/preferences-form.tsx +247 -52
  15. package/src/components/resource-table.tsx +7 -3
  16. package/src/hooks/use-display-name.ts +11 -3
  17. package/src/hooks/use-file-upload.ts +51 -0
  18. package/src/hooks/use-import.ts +5 -5
  19. package/src/hooks/use-money-locale.ts +14 -0
  20. package/src/index.ts +8 -3
  21. package/src/lib/currencies.ts +5 -2
  22. package/src/lib/form-mappers.ts +25 -5
  23. package/src/lib/formatters.ts +34 -4
  24. package/src/lib/fulfillment-items.test.ts +2 -1
  25. package/src/lib/fulfillment-items.ts +9 -3
  26. package/src/lib/i18n.ts +4 -4
  27. package/src/lib/iso-codes.ts +45 -0
  28. package/src/lib/money.test.ts +63 -0
  29. package/src/lib/money.ts +99 -0
  30. package/src/{products → lib}/normalize-money.ts +2 -2
  31. package/src/lib/return-refunds.test.ts +62 -0
  32. package/src/lib/return-refunds.ts +70 -0
  33. package/src/lib/route-registry.ts +58 -15
  34. package/src/lib/slot-registry.ts +10 -0
  35. package/src/lib/storefront.ts +2 -2
  36. package/src/locales/ar.json +4 -1
  37. package/src/locales/de.json +4 -1
  38. package/src/locales/en.json +4 -1
  39. package/src/locales/es.json +4 -1
  40. package/src/locales/fr.json +4 -1
  41. package/src/locales/pl.json +4 -1
  42. package/src/locales/zh-CN.json +4 -1
  43. package/src/plugin.ts +5 -5
  44. package/src/products/bulk-variants-dialog.tsx +1 -1
  45. package/src/products/product-bulk-price-editor.tsx +18 -29
  46. package/src/products/product-form-cards.tsx +12 -8
  47. package/src/products/product-form-mapping.ts +15 -11
  48. package/src/products/product-schema.ts +6 -8
  49. package/src/products/product-types.ts +5 -3
  50. package/src/products/use-product-form-data.ts +0 -28
  51. package/src/providers/store-provider.tsx +9 -9
  52. package/src/schemas/stock-location.ts +3 -2
  53. package/src/tables/package-types.tsx +1 -1
  54. package/src/tables/stock-locations.tsx +1 -1
  55. package/src/vite/discover.ts +33 -28
  56. package/src/hooks/use-direct-upload.ts +0 -117
  57. /package/src/{products → lib}/currency-parts.ts +0 -0
  58. /package/src/{products → lib}/normalize-money.test.ts +0 -0
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './components/locale-select'
33
33
  export * from './components/market-combobox'
34
34
  export * from './components/media-picker-sheet'
35
35
  export * from './components/mobile-top-bar'
36
+ export * from './components/money'
36
37
  export * from './components/nav-main'
37
38
  export * from './components/page-header'
38
39
  export * from './components/page-tabs'
@@ -62,12 +63,13 @@ export * from './hooks/use-auto-collapse-sidebar'
62
63
  export * from './hooks/use-command-palette'
63
64
  export * from './hooks/use-countries'
64
65
  export * from './hooks/use-custom-fields'
65
- export * from './hooks/use-direct-upload'
66
66
  export * from './hooks/use-display-name'
67
67
  export * from './hooks/use-export'
68
+ export * from './hooks/use-file-upload'
68
69
  export * from './hooks/use-global-search'
69
70
  export * from './hooks/use-host-form'
70
71
  export * from './hooks/use-import'
72
+ export * from './hooks/use-money-locale'
71
73
  export * from './hooks/use-package-types'
72
74
  export * from './hooks/use-resource-mutation'
73
75
  export * from './hooks/use-stock-locations'
@@ -79,6 +81,7 @@ export * from './lib/create-action-registry'
79
81
  // Registries — pluggable extension points (nav, route, slot, table,
80
82
  // settings-nav, form fields, custom field components)
81
83
  // ---------------------------------------------------------------------------
84
+ export * from './lib/currency-parts'
82
85
  export * from './lib/custom-field-components'
83
86
  export * from './lib/date-presets'
84
87
  export * from './lib/delivery-form'
@@ -98,12 +101,16 @@ export * from './lib/fulfillment-items'
98
101
  // `i18n.addResourceBundle`) get it via the named re-export.
99
102
  export * from './lib/i18n'
100
103
  export * from './lib/import-types'
104
+ export * from './lib/iso-codes'
101
105
  export * from './lib/label-upload-form'
106
+ export * from './lib/money'
102
107
  export * from './lib/nav-registry'
108
+ export * from './lib/normalize-money'
103
109
  export * from './lib/permissions'
104
110
  export * from './lib/progress'
105
111
  export * from './lib/query-client'
106
112
  export * from './lib/query-keys'
113
+ export * from './lib/return-refunds'
107
114
  export * from './lib/route-registry'
108
115
  export * from './lib/search-registry'
109
116
  export * from './lib/settings-nav-registry'
@@ -118,9 +125,7 @@ export * from './pages/stock-locations'
118
125
  // Plugin facade — re-exported for convenience; same API as `/plugin` subpath
119
126
  // ---------------------------------------------------------------------------
120
127
  export * from './plugin'
121
- export * from './products/currency-parts'
122
128
  export * from './products/normalize-customs'
123
- export * from './products/normalize-money'
124
129
  export * from './products/normalize-quantity'
125
130
  export * from './products/product-form-cards'
126
131
  export * from './products/product-form-mapping'
@@ -1,3 +1,6 @@
1
+ import type { CurrencyCode } from '@spree/admin-sdk'
2
+ import { isCurrencyCode } from './iso-codes'
3
+
1
4
  /**
2
5
  * Every ISO 4217 currency code the runtime knows about — the active-currency
3
6
  * counterpart to the Rails admin's `Money::Currency.table` list. Used for
@@ -6,9 +9,9 @@
6
9
  * runtimes without `Intl.supportedValuesOf`, so callers fall back to
7
10
  * `supported_currencies`.
8
11
  */
9
- export const ALL_CURRENCY_CODES: string[] = (() => {
12
+ export const ALL_CURRENCY_CODES: CurrencyCode[] = (() => {
10
13
  try {
11
- return Intl.supportedValuesOf('currency')
14
+ return Intl.supportedValuesOf('currency').filter(isCurrencyCode)
12
15
  } catch {
13
16
  return []
14
17
  }
@@ -1,3 +1,4 @@
1
+ import type { CountryCode } from '@spree/admin-sdk'
1
2
  import type { PanelAccountParams } from '../api-client'
2
3
 
3
4
  /**
@@ -91,7 +92,7 @@ export function editableAddressToStockLocationParams(values: {
91
92
  address2?: string | null
92
93
  city?: string | null
93
94
  postal_code?: string | null
94
- country_code?: string | null
95
+ country_code?: CountryCode | null
95
96
  state_code?: string | null
96
97
  phone?: string | null
97
98
  }) {
@@ -135,12 +136,31 @@ export function accountFormToParams(values: {
135
136
  first_name: values.first_name ?? undefined,
136
137
  last_name: values.last_name ?? undefined,
137
138
  selected_locale: blankToUndefined(values.selected_locale),
138
- ...avatarParam(values.avatar_signed_id, values.avatar_cleared),
139
+ ...signedIdParam('avatar', values.avatar_signed_id, values.avatar_cleared),
139
140
  }
140
141
  }
141
142
 
142
- function avatarParam(signedId: string | null, cleared: boolean) {
143
- if (signedId) return { avatar: signedId }
144
- if (cleared) return { avatar: null }
143
+ /**
144
+ * The write for one attachment slot, under the API's `<slot>_signed_id` name:
145
+ * a fresh upload sends its signed id, an explicit clear sends `null` to remove
146
+ * the file, and an untouched field is left out entirely.
147
+ */
148
+ export function signedIdParam<Slot extends string>(
149
+ slot: Slot,
150
+ signedId: string | null | undefined,
151
+ ): Partial<Record<`${Slot}_signed_id`, string>>
152
+ export function signedIdParam<Slot extends string>(
153
+ slot: Slot,
154
+ signedId: string | null | undefined,
155
+ cleared: boolean | undefined,
156
+ ): Partial<Record<`${Slot}_signed_id`, string | null>>
157
+ export function signedIdParam<Slot extends string>(
158
+ slot: Slot,
159
+ signedId: string | null | undefined,
160
+ cleared?: boolean,
161
+ ): Partial<Record<`${Slot}_signed_id`, string | null>> {
162
+ const key = `${slot}_signed_id` as `${Slot}_signed_id`
163
+ if (signedId) return { [key]: signedId } as Record<`${Slot}_signed_id`, string>
164
+ if (cleared) return { [key]: null } as Record<`${Slot}_signed_id`, null>
145
165
  return {}
146
166
  }
@@ -1,10 +1,40 @@
1
- import type { Price } from '@spree/admin-sdk'
1
+ import { formatMoney, type Price } from '@spree/admin-sdk'
2
2
  import { parseISO } from 'date-fns'
3
3
  import { formatInTimeZone } from 'date-fns-tz'
4
+ import i18n from 'i18next'
4
5
 
5
- export function formatPrice(price: Pick<Price, 'amount' | 'currency' | 'display_amount'> | null) {
6
- if (!price) return '—'
7
- return price.display_amount ?? `${price.currency} ${price.amount}`
6
+ export { formatMoney }
7
+
8
+ /**
9
+ * A price's amount in its currency, in the admin's own number format. A unit
10
+ * price keeps a fraction of a cent. `display_amount` is accepted and ignored
11
+ * so callers built against the old shape keep compiling.
12
+ */
13
+ export function formatPrice(
14
+ price: (Pick<Price, 'amount' | 'currency'> & { display_amount?: string | null }) | null,
15
+ locale: string = i18n.language || 'en',
16
+ ) {
17
+ if (!price?.amount || !price.currency) return '—'
18
+ return formatMoney(price.amount, price.currency, locale, { unitPrice: true })
19
+ }
20
+
21
+ /**
22
+ * Formats an API amount (a decimal string) in its currency, falling back to
23
+ * the bare number for a currency `Intl` does not know, and to `fallback` when
24
+ * either is missing.
25
+ */
26
+ export function formatAmount(
27
+ amount: string | null | undefined,
28
+ currency: string | null | undefined,
29
+ locale?: string,
30
+ fallback = '—',
31
+ ): string {
32
+ if (amount === null || amount === undefined || amount === '' || !currency) return fallback
33
+ try {
34
+ return formatMoney(amount, currency, locale) || fallback
35
+ } catch {
36
+ return `${amount} ${currency}`
37
+ }
8
38
  }
9
39
 
10
40
  export function formatStoreDateTime(iso: string, timezone: string) {
@@ -14,7 +14,8 @@ function lineItem(overrides: Partial<GroupableLineItem> = {}): GroupableLineItem
14
14
  options_text: 'Blue / M',
15
15
  quantity: 2,
16
16
  thumbnail_url: 'https://example.test/shirt.jpg',
17
- display_price: '$10.00',
17
+ price: '10.00',
18
+ currency: 'USD',
18
19
  ...overrides,
19
20
  }
20
21
  }
@@ -1,3 +1,6 @@
1
+ import i18n from 'i18next'
2
+ import { formatMoney } from './formatters'
3
+
1
4
  /**
2
5
  * Which line items sit in which fulfillment, and which units nobody has
3
6
  * claimed yet. The order payload describes the two halves separately —
@@ -14,8 +17,8 @@ export interface GroupableLineItem {
14
17
  options_text?: string | null
15
18
  quantity: number
16
19
  thumbnail_url?: string | null
17
- display_price?: string | null
18
- display_total?: string | null
20
+ price?: string | null
21
+ currency?: string | null
19
22
  }
20
23
 
21
24
  /** The fulfillment-item fields the grouping reads. */
@@ -130,7 +133,10 @@ function buildRow(
130
133
  name: copy?.name ?? fallback?.name ?? '',
131
134
  optionsText: copy?.options_text ?? fallback?.options_text ?? null,
132
135
  thumbnailUrl: copy?.thumbnail_url ?? null,
133
- displayPrice: copy?.display_price ?? null,
136
+ displayPrice:
137
+ copy?.price && copy.currency
138
+ ? formatMoney(copy.price, copy.currency, i18n.language || 'en', { unitPrice: true })
139
+ : null,
134
140
  quantity,
135
141
  }
136
142
  }
package/src/lib/i18n.ts CHANGED
@@ -10,7 +10,7 @@ import en from '../locales/en.json'
10
10
  // made anywhere is honored on the next paint.
11
11
  export const ADMIN_LOCALE_STORAGE_KEY = 'spree-admin-locale'
12
12
 
13
- // Marks the locale key as auto-applied from a store's `preferred_admin_locale`
13
+ // Marks the locale key as auto-applied from a store's `admin_locale`
14
14
  // (vs an explicit choice). Holds the storeId whose default is currently in
15
15
  // effect, so crossing into a different store can re-apply that store's default
16
16
  // while a genuine choice (switcher/profile/login) still wins everywhere. Mirrors
@@ -69,10 +69,10 @@ function readStoredLocale(): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Reconcile the admin UI language against a store's `preferred_admin_locale`
72
+ * Reconcile the admin UI language against a store's `admin_locale`
73
73
  * fallback (legacy `base_controller` parity), for an admin with no genuine
74
74
  * personal choice. Encodes the precedence:
75
- * account selected_locale > genuine stored choice > store preferred_admin_locale > 'en'
75
+ * account selected_locale > genuine stored choice > store admin_locale > 'en'
76
76
  *
77
77
  * No-op when a higher tier owns the language (an account locale, or a genuine
78
78
  * stored choice — locale key set without an auto-marker). Otherwise drives the
@@ -83,7 +83,7 @@ function readStoredLocale(): string {
83
83
  * - already matching → nothing changes.
84
84
  * Reloads (via the boot path) only when the displayed language actually changes.
85
85
  *
86
- * @param code the store's current `preferred_admin_locale`
86
+ * @param code the store's current `admin_locale`
87
87
  * @param storeId the store being entered
88
88
  * @param accountLocale the account's `selected_locale` (null when unset)
89
89
  * @param supported locale codes the dashboard ships a bundle for
@@ -0,0 +1,45 @@
1
+ import type { CountryCode, CurrencyCode, LocaleCode } from '@spree/admin-sdk'
2
+ import i18n from 'i18next'
3
+ import { z } from 'zod/v4'
4
+
5
+ /**
6
+ * Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
7
+ *
8
+ * Shape only: the server holds the authoritative list and rejects a code it
9
+ * does not know, so the dashboard does not ship a second copy of it. This is
10
+ * the one place a plain string becomes a `CountryCode`.
11
+ */
12
+ export function isCountryCode(value: unknown): value is CountryCode {
13
+ return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
14
+ }
15
+
16
+ /** Whether a value has the shape of an ISO 4217 currency code. See `isCountryCode`. */
17
+ export function isCurrencyCode(value: unknown): value is CurrencyCode {
18
+ return typeof value === 'string' && /^[A-Z]{3}$/.test(value)
19
+ }
20
+
21
+ /**
22
+ * Whether a value has the shape of a locale code: an ISO 639-1 language,
23
+ * optionally with a region (`en`, `pt-BR`, `es-419`). See `isCountryCode`.
24
+ */
25
+ export function isLocaleCode(value: unknown): value is LocaleCode {
26
+ return typeof value === 'string' && /^[a-z]{2,3}(-[A-Z0-9]{2,3})?$/.test(value)
27
+ }
28
+
29
+ /**
30
+ * A required country field. A form that allows leaving it blank widens it
31
+ * with `.or(z.literal(''))` and sends `value || null` (or `|| undefined`).
32
+ */
33
+ export const countryCodeSchema = z.custom<CountryCode>(isCountryCode, {
34
+ error: () => i18n.t('admin.validation.country_required'),
35
+ })
36
+
37
+ /** A required currency field. See `countryCodeSchema`. */
38
+ export const currencyCodeSchema = z.custom<CurrencyCode>(isCurrencyCode, {
39
+ error: () => i18n.t('admin.validation.currency_required'),
40
+ })
41
+
42
+ /** A required locale field. See `countryCodeSchema`. */
43
+ export const localeCodeSchema = z.custom<LocaleCode>(isLocaleCode, {
44
+ error: () => i18n.t('admin.validation.locale_required'),
45
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ fractionToPercent,
4
+ isPositiveMoney,
5
+ percentOf,
6
+ percentToFraction,
7
+ prorateMoney,
8
+ shiftDecimalPoint,
9
+ } from './money'
10
+
11
+ describe('shiftDecimalPoint', () => {
12
+ it('moves the point exactly, without float noise', () => {
13
+ expect(shiftDecimalPoint('0.077', 2)).toBe('7.7')
14
+ expect(shiftDecimalPoint('7.7', -2)).toBe('0.077')
15
+ expect(shiftDecimalPoint('0.07', 2)).toBe('7')
16
+ expect(shiftDecimalPoint('1', 2)).toBe('100')
17
+ expect(shiftDecimalPoint('100', -2)).toBe('1')
18
+ expect(shiftDecimalPoint('-0.5', 2)).toBe('-50')
19
+ expect(shiftDecimalPoint('0', -2)).toBe('0')
20
+ })
21
+
22
+ it('leaves a value that is not a decimal string alone', () => {
23
+ expect(shiftDecimalPoint('abc', 2)).toBe('abc')
24
+ })
25
+ })
26
+
27
+ describe('fractionToPercent / percentToFraction', () => {
28
+ it('round-trips and keeps blanks blank', () => {
29
+ expect(fractionToPercent('0.23')).toBe('23')
30
+ expect(percentToFraction('23')).toBe('0.23')
31
+ expect(percentToFraction('7.25')).toBe('0.0725')
32
+ expect(fractionToPercent(null)).toBe('')
33
+ expect(percentToFraction('')).toBe('')
34
+ })
35
+ })
36
+
37
+ describe('percentOf', () => {
38
+ it('rounds to the currency decimals', () => {
39
+ expect(percentOf('19.99', '10', 'USD')).toBe('2.00')
40
+ expect(percentOf('100', '12.5', 'USD')).toBe('12.50')
41
+ expect(percentOf('1000', '0.05', 'JPY')).toBe('1')
42
+ expect(percentOf('1.005', '50', 'KWD')).toBe('0.503')
43
+ })
44
+ })
45
+
46
+ describe('isPositiveMoney', () => {
47
+ it('is true only for an amount above zero', () => {
48
+ expect(isPositiveMoney('0.01')).toBe(true)
49
+ expect(isPositiveMoney('0.00')).toBe(false)
50
+ expect(isPositiveMoney('-1')).toBe(false)
51
+ expect(isPositiveMoney(null)).toBe(false)
52
+ expect(isPositiveMoney('abc')).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('prorateMoney', () => {
57
+ it('shares an amount across units, rounded to the currency', () => {
58
+ expect(prorateMoney('10.00', 1, 3, 'USD')).toBe('3.33')
59
+ expect(prorateMoney('10.00', 2, 3, 'USD')).toBe('6.67')
60
+ expect(prorateMoney('10.00', 3, 3, 'USD')).toBe('10.00')
61
+ expect(prorateMoney('1000', 1, 3, 'JPY')).toBe('333')
62
+ })
63
+ })
@@ -0,0 +1,99 @@
1
+ import { compareMoney, decimalPlaces, isDecimalString, sumMoney } from '@spree/admin-sdk'
2
+
3
+ /** Whether an amount is a decimal string above zero. Blank or malformed is not. */
4
+ export function isPositiveMoney(value: string | null | undefined): boolean {
5
+ return isDecimalString(value) && compareMoney(value, '0') > 0
6
+ }
7
+
8
+ /**
9
+ * Moves the decimal point of a decimal string by whole places, exactly, and
10
+ * writes the result without trailing zeros ("0.077" shifted by 2 is "7.7").
11
+ * Anything that is not a decimal string comes back unchanged.
12
+ */
13
+ export function shiftDecimalPoint(value: string, places: number): string {
14
+ if (!isDecimalString(value)) return value
15
+
16
+ const negative = value.startsWith('-')
17
+ const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
18
+ let digits = whole + fraction
19
+ let point = whole.length + places
20
+ if (point < 0) {
21
+ digits = '0'.repeat(-point) + digits
22
+ point = 0
23
+ }
24
+ digits = digits.padEnd(point, '0')
25
+
26
+ const integer = digits.slice(0, point).replace(/^0+(?=\d)/, '') || '0'
27
+ const decimals = digits.slice(point).replace(/0+$/, '')
28
+ const text = decimals ? `${integer}.${decimals}` : integer
29
+ return negative && /[1-9]/.test(text) ? `-${text}` : text
30
+ }
31
+
32
+ /** A fraction rate ("0.23") as the percentage a merchant reads ("23"). Blank stays blank. */
33
+ export function fractionToPercent(fraction: string | null | undefined): string {
34
+ return fraction == null || fraction === '' ? '' : shiftDecimalPoint(fraction, 2)
35
+ }
36
+
37
+ /** A typed percentage ("23") as the fraction rate the API stores ("0.23"). Blank stays blank. */
38
+ export function percentToFraction(percent: string | null | undefined): string {
39
+ return percent == null || percent === '' ? '' : shiftDecimalPoint(percent.trim(), -2)
40
+ }
41
+
42
+ function scaled(value: string): { units: bigint; scale: number } {
43
+ const negative = value.startsWith('-')
44
+ const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
45
+ const units = BigInt(whole + fraction)
46
+ return { units: negative ? -units : units, scale: fraction.length }
47
+ }
48
+
49
+ /**
50
+ * numerator / denominator, where the numerator carries `scale` decimals,
51
+ * rounded half away from zero to the currency's decimals.
52
+ */
53
+ function roundToCurrency(
54
+ numerator: bigint,
55
+ scale: number,
56
+ denominator: bigint,
57
+ currency: string,
58
+ ): string {
59
+ const places = decimalPlaces(currency)
60
+ const negative = numerator < 0n !== denominator < 0n
61
+ let dividend = numerator < 0n ? -numerator : numerator
62
+ let divisor = denominator < 0n ? -denominator : denominator
63
+ if (scale > places) divisor *= 10n ** BigInt(scale - places)
64
+ else dividend *= 10n ** BigInt(places - scale)
65
+
66
+ let units = dividend / divisor
67
+ if ((dividend % divisor) * 2n >= divisor) units += 1n
68
+
69
+ const text = shiftDecimalPoint((negative ? -units : units).toString(), -places)
70
+ return sumMoney([text], currency)
71
+ }
72
+
73
+ /**
74
+ * A percentage of an amount, exactly, rounded half away from zero to the
75
+ * currency's decimals ("10" percent of "19.99" USD is "2.00").
76
+ */
77
+ export function percentOf(amount: string, percent: string, currency: string): string {
78
+ const left = scaled(amount)
79
+ const right = scaled(percent)
80
+ // The percentage is a hundredth, so the product carries two more places.
81
+ return roundToCurrency(left.units * right.units, left.scale + right.scale + 2, 1n, currency)
82
+ }
83
+
84
+ /**
85
+ * The share of an amount that `part` of `whole` units carries ("10.00" for 1
86
+ * of 3 is "3.33"), rounded half away from zero to the currency's decimals.
87
+ */
88
+ export function prorateMoney(
89
+ amount: string,
90
+ part: number,
91
+ whole: number,
92
+ currency: string,
93
+ ): string {
94
+ if (!Number.isInteger(part) || !Number.isInteger(whole) || whole === 0) {
95
+ throw new Error(`Cannot prorate ${part} of ${whole}`)
96
+ }
97
+ const { units, scale } = scaled(amount)
98
+ return roundToCurrency(units * BigInt(part), scale, BigInt(whole), currency)
99
+ }
@@ -4,8 +4,8 @@
4
4
  // dashboard converts here, in the browser, and never relies on the server to
5
5
  // parse comma-vs-period. See docs/plans/5.5-client-side-money-normalization.md.
6
6
  //
7
- // The locale is the currency's display locale (its market locale, e.g. EUR →
8
- // `de`), so the same locale drives both display formatting and this parse.
7
+ // The locale is the admin's own (`useMoneyLocale`), the same one the field is
8
+ // displayed in, so a value reads back exactly as it was shown.
9
9
 
10
10
  /**
11
11
  * Derives a locale's decimal and group separators via `Intl`. `fr` groups with
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { paidForUnits, returnOwesNothing, returnRefundSummary } from './return-refunds'
3
+
4
+ const figures = {
5
+ status: 'received',
6
+ refund_total: '20.00',
7
+ refunded_total: '0.00',
8
+ }
9
+
10
+ describe('returnRefundSummary', () => {
11
+ it('reports what is owed until money goes back', () => {
12
+ expect(returnRefundSummary(figures, 'USD', 'en')).toEqual({ kind: 'owed', amount: '$20.00' })
13
+ })
14
+
15
+ it('names the full amount when less went back than was owed', () => {
16
+ expect(
17
+ returnRefundSummary(
18
+ {
19
+ ...figures,
20
+ status: 'refunded',
21
+ refunded_total: '15.00',
22
+ },
23
+ 'USD',
24
+ 'en',
25
+ ),
26
+ ).toEqual({ kind: 'refunded_short', amount: '$15.00', total: '$20.00' })
27
+ })
28
+
29
+ it('reports a full refund', () => {
30
+ expect(
31
+ returnRefundSummary(
32
+ {
33
+ ...figures,
34
+ status: 'refunded',
35
+ refunded_total: '20.00',
36
+ },
37
+ 'USD',
38
+ 'en',
39
+ ),
40
+ ).toEqual({ kind: 'refunded', amount: '$20.00' })
41
+ })
42
+ })
43
+
44
+ describe('returnOwesNothing', () => {
45
+ it('is true only for a zero amount', () => {
46
+ expect(returnOwesNothing('0.00')).toBe(true)
47
+ expect(returnOwesNothing('0.01')).toBe(false)
48
+ })
49
+ })
50
+
51
+ describe('paidForUnits', () => {
52
+ it('shares the paid amount and tax across the chosen units', () => {
53
+ const line = { quantity: 3, discounted_amount: '9.00', additional_tax_total: '1.00' }
54
+ expect(paidForUnits(line, 1, 'USD')).toBe('3.33')
55
+ expect(paidForUnits(line, 3, 'USD')).toBe('10.00')
56
+ })
57
+
58
+ it('is blank when nothing is chosen or the amount is unknown', () => {
59
+ expect(paidForUnits({ quantity: 2, discounted_amount: '5.00' }, 0, 'USD')).toBe('')
60
+ expect(paidForUnits({ quantity: 2, discounted_amount: null }, 1, 'USD')).toBe('')
61
+ })
62
+ })
@@ -0,0 +1,70 @@
1
+ import { compareMoney, isDecimalString, isZeroMoney, sumMoney } from '@spree/admin-sdk'
2
+ import { formatMoney } from './formatters'
3
+ import { prorateMoney } from './money'
4
+
5
+ /** True when a received return is owed nothing, so completing it moves no money. */
6
+ export function returnOwesNothing(refundableTotal: string): boolean {
7
+ return isZeroMoney(refundableTotal)
8
+ }
9
+
10
+ export type ReturnRefundFigures = {
11
+ status: string
12
+ refund_total: string
13
+ refunded_total: string
14
+ }
15
+
16
+ /**
17
+ * What a return's card reports: what it is owed until money goes back, then
18
+ * what actually went back — which a merchant keeping a restocking fee makes
19
+ * less than it was owed, so that case also names the full amount. Money that
20
+ * went back counts even before the return is marked refunded: a refund that
21
+ * succeeded on one payment and was declined on the next leaves it received.
22
+ *
23
+ * A return carries no currency of its own, so the caller passes its order's.
24
+ * Without one the amounts come back as the API wrote them.
25
+ */
26
+ export function returnRefundSummary(
27
+ returnRecord: ReturnRefundFigures,
28
+ currency?: string,
29
+ locale?: string,
30
+ ):
31
+ | { kind: 'owed'; amount: string }
32
+ | { kind: 'refunded'; amount: string }
33
+ | { kind: 'refunded_short'; amount: string; total: string } {
34
+ const format = (amount: string) => (currency ? formatMoney(amount, currency, locale) : amount)
35
+
36
+ if (returnRecord.status !== 'refunded' && isZeroMoney(returnRecord.refunded_total)) {
37
+ return { kind: 'owed', amount: format(returnRecord.refund_total) }
38
+ }
39
+
40
+ if (compareMoney(returnRecord.refunded_total || '0', returnRecord.refund_total || '0') < 0) {
41
+ return {
42
+ kind: 'refunded_short',
43
+ amount: format(returnRecord.refunded_total),
44
+ total: format(returnRecord.refund_total),
45
+ }
46
+ }
47
+
48
+ return { kind: 'refunded', amount: format(returnRecord.refunded_total) }
49
+ }
50
+
51
+ /**
52
+ * What the customer paid, tax included, for `quantity` units of a line — the
53
+ * refund a claim offers until the merchant types their own. Blank when the
54
+ * line's amounts are unknown.
55
+ */
56
+ export function paidForUnits(
57
+ line: {
58
+ quantity: number
59
+ discounted_amount?: string | null
60
+ additional_tax_total?: string | null
61
+ },
62
+ quantity: number,
63
+ currency: string,
64
+ ): string {
65
+ if (quantity <= 0 || line.quantity <= 0 || !isDecimalString(line.discounted_amount)) return ''
66
+
67
+ const tax = isDecimalString(line.additional_tax_total) ? line.additional_tax_total : '0'
68
+ const paid = sumMoney([line.discounted_amount, tax])
69
+ return prorateMoney(paid, quantity, line.quantity, currency)
70
+ }