@spree/dashboard-core 1.0.0-rc.2 → 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.
- package/dist/vite/discover.js +5 -2
- package/package.json +3 -5
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +16 -10
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +247 -52
- package/src/components/resource-table.tsx +7 -3
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +5 -2
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +45 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +4 -1
- package/src/locales/de.json +4 -1
- package/src/locales/en.json +4 -1
- package/src/locales/es.json +4 -1
- package/src/locales/fr.json +4 -1
- package/src/locales/pl.json +4 -1
- package/src/locales/zh-CN.json +4 -1
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +1 -1
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +19 -2
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /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'
|
package/src/lib/currencies.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/lib/form-mappers.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
...
|
|
139
|
+
...signedIdParam('avatar', values.avatar_signed_id, values.avatar_cleared),
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/src/lib/formatters.ts
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
|
-
import type
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
|
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 `
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
+
})
|
package/src/lib/money.ts
ADDED
|
@@ -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
|
|
8
|
-
//
|
|
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
|
+
}
|