@stacksjs/defaults 0.70.352 → 0.70.354
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/ai/skills/stacks-queue/SKILL.md +23 -0
- package/app/Actions/Dashboard/Commerce/tax-rate-records.ts +9 -0
- package/app/Models/commerce/TaxRate.ts +48 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +23 -0
- package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +4 -0
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +9 -1
- package/resources/plugins/preloader.ts +90 -7
- package/views/dashboard/layouts/default.stx +4 -4
|
@@ -298,11 +298,34 @@ const wrappedHandler = withEvents('emails', originalHandler)
|
|
|
298
298
|
```
|
|
299
299
|
|
|
300
300
|
### OnQueueEvent Decorator
|
|
301
|
+
Subscribes an **instance method** on `new`, with `this` bound to that instance.
|
|
302
|
+
Declaring the class subscribes nothing, so the class has to be constructed — and
|
|
303
|
+
the instance has to stay referenced, because the global emitter holds listeners
|
|
304
|
+
weakly (a collected listener stops receiving events).
|
|
305
|
+
|
|
301
306
|
```typescript
|
|
302
307
|
class MyHandler {
|
|
303
308
|
@OnQueueEvent('job:failed')
|
|
304
309
|
handleFailed(payload: QueueEventPayload) { ... }
|
|
305
310
|
}
|
|
311
|
+
|
|
312
|
+
// Required: subscription happens here, and this binding keeps it alive.
|
|
313
|
+
export const myHandler = new MyHandler()
|
|
314
|
+
|
|
315
|
+
// Deterministic teardown (otherwise it lasts as long as the instance does)
|
|
316
|
+
getQueueEvents().unsubscribeListener(myHandler)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Rejected with a `TypeError` at class-definition time: static methods, fields,
|
|
320
|
+
accessors and whole classes (no instance to bind to — use `onQueueEvent(...)`),
|
|
321
|
+
and legacy `experimentalDecorators` decoration (no construction-time hook).
|
|
322
|
+
|
|
323
|
+
### Listener Introspection
|
|
324
|
+
```typescript
|
|
325
|
+
const events = getQueueEvents()
|
|
326
|
+
events.subscribeListener(obj, 'job:failed', fn) // weak, `this` === obj
|
|
327
|
+
events.unsubscribeListener(obj) // → count removed
|
|
328
|
+
events.listenerCount('job:failed') // live handlers ('*' for wildcards)
|
|
306
329
|
```
|
|
307
330
|
|
|
308
331
|
### QueueMetrics
|
|
@@ -19,6 +19,10 @@ export interface TaxRateRecord {
|
|
|
19
19
|
region: TaxRateRegion
|
|
20
20
|
status: 'active' | 'inactive'
|
|
21
21
|
isDefault: boolean
|
|
22
|
+
/** Stable identifier application code matches on, independent of `name`. */
|
|
23
|
+
code: string
|
|
24
|
+
/** Whether a qualifying exemption stops this component being charged. */
|
|
25
|
+
exemptible: boolean
|
|
22
26
|
createdAt: string
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -50,6 +54,11 @@ export function normalizeTaxRateRecord(record: any): TaxRateRecord {
|
|
|
50
54
|
]),
|
|
51
55
|
status: commerceEnum(commerceValue(record, 'status'), source, 'status', ['active', 'inactive']),
|
|
52
56
|
isDefault: commerceBoolean(commerceValue(record, 'is_default', 'isDefault'), source, 'is_default'),
|
|
57
|
+
// Optional, both of them: a rate created before these columns existed has
|
|
58
|
+
// neither, and a dashboard that throws on an older row is worse than one
|
|
59
|
+
// that shows it without a badge.
|
|
60
|
+
code: String(commerceValue(record, 'code') ?? ''),
|
|
61
|
+
exemptible: Boolean(commerceValue(record, 'exemptible') ?? false),
|
|
53
62
|
createdAt: commerceTimestamp(commerceValue(record, 'created_at', 'createdAt'), source),
|
|
54
63
|
}
|
|
55
64
|
}
|
|
@@ -111,6 +111,54 @@ export default defineModel({
|
|
|
111
111
|
},
|
|
112
112
|
factory: () => false,
|
|
113
113
|
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A stable name for this component, for code to reference.
|
|
117
|
+
*
|
|
118
|
+
* `name` is what an operator reads and edits in the dashboard, so it is
|
|
119
|
+
* the wrong thing for an application to branch on — renaming "State sales
|
|
120
|
+
* tax" to "Sales tax (CA)" should not change what gets charged. A code is
|
|
121
|
+
* the identifier that survives the rename.
|
|
122
|
+
*
|
|
123
|
+
* Free-form on purpose. What counts as a component differs by
|
|
124
|
+
* jurisdiction: `vat`, `gst`, `excise`, `city`, `eco-fee`.
|
|
125
|
+
*/
|
|
126
|
+
code: {
|
|
127
|
+
order: 8,
|
|
128
|
+
fillable: true,
|
|
129
|
+
default: '',
|
|
130
|
+
validation: {
|
|
131
|
+
rule: schema.string().max(64),
|
|
132
|
+
message: {
|
|
133
|
+
max: 'Code must have a maximum of 64 characters',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
factory: faker => faker.helpers.arrayElement(['vat', 'gst', 'sales', 'excise', 'city']),
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a qualifying exemption removes this component.
|
|
141
|
+
*
|
|
142
|
+
* Most places tax in parts, and an exemption usually lifts some of them
|
|
143
|
+
* and not others: groceries escape VAT but not a deposit levy, and a
|
|
144
|
+
* Californian medical cannabis patient is exempt from sales tax while
|
|
145
|
+
* still paying excise and the city's business tax. Modelling tax as one
|
|
146
|
+
* blended number cannot say that, so an app either over-charges the
|
|
147
|
+
* exempt customer or under-collects tax it owes.
|
|
148
|
+
*
|
|
149
|
+
* What *qualifies* is the application's business — a card, a resale
|
|
150
|
+
* certificate, a charity registration. This only records that the
|
|
151
|
+
* component is the kind that can be lifted.
|
|
152
|
+
*/
|
|
153
|
+
exemptible: {
|
|
154
|
+
order: 9,
|
|
155
|
+
fillable: true,
|
|
156
|
+
default: false,
|
|
157
|
+
validation: {
|
|
158
|
+
rule: schema.boolean(),
|
|
159
|
+
},
|
|
160
|
+
factory: () => false,
|
|
161
|
+
},
|
|
114
162
|
},
|
|
115
163
|
|
|
116
164
|
dashboard: {
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.354",
|
|
6
6
|
"description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"license": "MIT",
|
|
@@ -15,6 +15,8 @@ const country = state('')
|
|
|
15
15
|
const region = state('North America')
|
|
16
16
|
const status = state<'active' | 'inactive'>('active')
|
|
17
17
|
const isDefault = state(false)
|
|
18
|
+
const code = state('')
|
|
19
|
+
const exemptible = state(false)
|
|
18
20
|
let hydrationKey = ''
|
|
19
21
|
|
|
20
22
|
const regions = ['North America', 'South America', 'Europe', 'Asia', 'Africa', 'Oceania', 'Antarctica'] as const
|
|
@@ -36,6 +38,8 @@ effect(() => {
|
|
|
36
38
|
region.set(current?.region || 'North America')
|
|
37
39
|
status.set(current?.status || 'active')
|
|
38
40
|
isDefault.set(current?.isDefault || false)
|
|
41
|
+
code.set(current?.code || '')
|
|
42
|
+
exemptible.set(current?.exemptible || false)
|
|
39
43
|
})
|
|
40
44
|
|
|
41
45
|
function submit(): void {
|
|
@@ -49,6 +53,8 @@ function submit(): void {
|
|
|
49
53
|
region: region(),
|
|
50
54
|
status: status(),
|
|
51
55
|
isDefault: isDefault(),
|
|
56
|
+
code: code().trim(),
|
|
57
|
+
exemptible: exemptible(),
|
|
52
58
|
})
|
|
53
59
|
}
|
|
54
60
|
|
|
@@ -105,6 +111,11 @@ function errorMessages(): string[] {
|
|
|
105
111
|
</select>
|
|
106
112
|
</label>
|
|
107
113
|
</div>
|
|
114
|
+
<label class="block">
|
|
115
|
+
<span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Code</span>
|
|
116
|
+
<input x-model="code" type="text" placeholder="sales" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
|
|
117
|
+
<span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">What the application matches on. Renaming the rate above does not change what is charged; changing this can.</span>
|
|
118
|
+
</label>
|
|
108
119
|
<label class="flex gap-3 items-start p-3 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
109
120
|
<input x-model="isDefault" type="checkbox" class="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded" />
|
|
110
121
|
<span>
|
|
@@ -112,6 +123,18 @@ function errorMessages(): string[] {
|
|
|
112
123
|
<span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Selecting this clears the default flag from every other rate.</span>
|
|
113
124
|
</span>
|
|
114
125
|
</label>
|
|
126
|
+
{{--
|
|
127
|
+
Spelled out rather than labelled "exempt", because the two read
|
|
128
|
+
alike and mean opposite things: this marks the component as one an
|
|
129
|
+
exemption can lift, not one that is currently unpaid.
|
|
130
|
+
--}}
|
|
131
|
+
<label class="flex gap-3 items-start p-3 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
132
|
+
<input x-model="exemptible" type="checkbox" class="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded" />
|
|
133
|
+
<span>
|
|
134
|
+
<span class="block font-medium text-gray-700 text-sm dark:text-neutral-200">Can be exempted</span>
|
|
135
|
+
<span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Customers who qualify for an exemption are not charged this component. Leave off for anything owed regardless — an excise or a city levy is usually still due.</span>
|
|
136
|
+
</span>
|
|
137
|
+
</label>
|
|
115
138
|
</div>
|
|
116
139
|
</div>
|
|
117
140
|
<footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
|
|
@@ -16,6 +16,8 @@ interface TaxRateWritePayload {
|
|
|
16
16
|
region: string
|
|
17
17
|
status: 'active' | 'inactive'
|
|
18
18
|
isDefault: boolean
|
|
19
|
+
code: string
|
|
20
|
+
exemptible: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const emptySummary: TaxRateSummary = { total: 0, active: 0, countries: 0, averageRate: 0, defaultConflicts: 0 }
|
|
@@ -136,6 +138,8 @@ async function saveTax(payload: TaxRateWritePayload): Promise<void> {
|
|
|
136
138
|
region: payload.region,
|
|
137
139
|
status: payload.status,
|
|
138
140
|
isDefault: payload.isDefault,
|
|
141
|
+
code: payload.code,
|
|
142
|
+
exemptible: payload.exemptible,
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
saving.set(true)
|
|
@@ -39,9 +39,17 @@ function formatDate(value: string): string {
|
|
|
39
39
|
<template :for="record in records()" :key="record.id">
|
|
40
40
|
<tr class="align-top">
|
|
41
41
|
<td class="px-4 py-4">
|
|
42
|
-
<div class="flex gap-2 items-center">
|
|
42
|
+
<div class="flex flex-wrap gap-2 items-center">
|
|
43
43
|
<p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
|
|
44
44
|
<span :if="record.isDefault" class="px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">Default</span>
|
|
45
|
+
{{--
|
|
46
|
+
Both badges change what a customer is charged, so they sit
|
|
47
|
+
next to the name rather than in a column someone has to
|
|
48
|
+
scroll to. The code is what application code matches on;
|
|
49
|
+
renaming the rate must not change the bill.
|
|
50
|
+
--}}
|
|
51
|
+
<span :if="record.code" class="px-2 py-0.5 font-mono text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full">{{ record.code }}</span>
|
|
52
|
+
<span :if="record.exemptible" class="px-2 py-0.5 font-medium text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 rounded-full" title="Not charged to a customer who qualifies for an exemption">Exemptible</span>
|
|
45
53
|
</div>
|
|
46
54
|
<p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.type }}</p>
|
|
47
55
|
</td>
|
|
@@ -128,6 +128,81 @@ if (!isRepl && !isPostinstall) {
|
|
|
128
128
|
// eslint-disable-next-line antfu/no-top-level-await
|
|
129
129
|
// await import('bun-plugin-stx')
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Whether a bare `@stacksjs/*` specifier resolves to something belonging to
|
|
133
|
+
* THIS project, and is therefore safe to import.
|
|
134
|
+
*
|
|
135
|
+
* A bare specifier resolves through node_modules, and when that is missing or
|
|
136
|
+
* half-installed bun falls back to its GLOBAL install cache. So a project with
|
|
137
|
+
* a broken install did not fail. It silently booted against whatever published
|
|
138
|
+
* version happened to be sitting in ~/.bun/install/cache, which is worse than
|
|
139
|
+
* loading nothing and completely invisible.
|
|
140
|
+
*
|
|
141
|
+
* It also hung, and that is how it was found. On Linux the first such
|
|
142
|
+
* cache-resolved import never settles: no rejection, no active handles, the
|
|
143
|
+
* process simply stops, so every stage of the preloader after it is silently
|
|
144
|
+
* unreachable. Both callers wrap their import in a `catch` that assumes a bad
|
|
145
|
+
* specifier fails FAST. That holds for one that cannot be resolved at all. It
|
|
146
|
+
* does not hold for one that resolves to a stale copy.
|
|
147
|
+
*
|
|
148
|
+
* ## Why this is a directory probe and not `Bun.resolveSync`
|
|
149
|
+
*
|
|
150
|
+
* The first version of this guard asked `Bun.resolveSync`, which answers the
|
|
151
|
+
* question exactly but pays full module resolution to do it. Measured on a
|
|
152
|
+
* Linux CI runner, a specifier that is NOT in `node_modules` cost **0.9 to 2.0
|
|
153
|
+
* seconds per call**, because bun walks the entire tree and then scans a global
|
|
154
|
+
* cache the install had just filled with 600+ packages. Twenty of those is 20
|
|
155
|
+
* to 40 seconds, so the guard turned a hang into a crawl and the preloader test
|
|
156
|
+
* kept timing out, intermittently, depending on how loaded the runner was.
|
|
157
|
+
*
|
|
158
|
+
* Locating the `node_modules/@stacksjs` directory once and then asking
|
|
159
|
+
* `existsSync` per package is the same question answered with stat calls:
|
|
160
|
+
* microseconds, and it never touches the global cache. The walk is memoised
|
|
161
|
+
* because the answer cannot change within a process.
|
|
162
|
+
*
|
|
163
|
+
* Accepted: a package present in this project's `@stacksjs` scope directory,
|
|
164
|
+
* which covers a real install and a vendored checkout alike (the framework's
|
|
165
|
+
* own core packages are symlinked into it). Anchored on `import.meta.dir`
|
|
166
|
+
* rather than the cwd, so running a command from a subdirectory does not change
|
|
167
|
+
* what loads.
|
|
168
|
+
*/
|
|
169
|
+
let stacksScopeDir: string | null | undefined
|
|
170
|
+
|
|
171
|
+
async function findStacksScopeDir(): Promise<string | null> {
|
|
172
|
+
if (stacksScopeDir !== undefined)
|
|
173
|
+
return stacksScopeDir
|
|
174
|
+
|
|
175
|
+
const { existsSync } = await import('node:fs')
|
|
176
|
+
const { dirname, join } = await import('node:path')
|
|
177
|
+
|
|
178
|
+
let dir = import.meta.dir
|
|
179
|
+
for (;;) {
|
|
180
|
+
const candidate = join(dir, 'node_modules', '@stacksjs')
|
|
181
|
+
if (existsSync(candidate)) {
|
|
182
|
+
stacksScopeDir = candidate
|
|
183
|
+
return candidate
|
|
184
|
+
}
|
|
185
|
+
const parent = dirname(dir)
|
|
186
|
+
if (parent === dir)
|
|
187
|
+
break
|
|
188
|
+
dir = parent
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
stacksScopeDir = null
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function belongsToThisProject(specifier: string): Promise<boolean> {
|
|
196
|
+
const scopeDir = await findStacksScopeDir()
|
|
197
|
+
if (!scopeDir)
|
|
198
|
+
return false
|
|
199
|
+
|
|
200
|
+
const { existsSync } = await import('node:fs')
|
|
201
|
+
const { join } = await import('node:path')
|
|
202
|
+
|
|
203
|
+
return existsSync(join(scopeDir, specifier.slice('@stacksjs/'.length)))
|
|
204
|
+
}
|
|
205
|
+
|
|
131
206
|
// Auto-import ALL Stacks framework modules into globalThis
|
|
132
207
|
// This allows using Action, response, Activity, etc. without ANY imports.
|
|
133
208
|
// Exported so server entrypoints (e.g. `dev/api.ts`) can opt back in
|
|
@@ -190,6 +265,12 @@ export async function loadAutoImports() {
|
|
|
190
265
|
]
|
|
191
266
|
|
|
192
267
|
for (const pkg of stacksPackages) {
|
|
268
|
+
// See `belongsToThisProject`. Skipping is what the `catch` below always
|
|
269
|
+
// meant to do; it just never got the chance for a specifier that resolves
|
|
270
|
+
// to a stale copy instead of failing.
|
|
271
|
+
if (!(await belongsToThisProject(pkg)))
|
|
272
|
+
continue
|
|
273
|
+
|
|
193
274
|
try {
|
|
194
275
|
const module = await import(pkg)
|
|
195
276
|
for (const [name, value] of Object.entries(module)) {
|
|
@@ -347,12 +428,14 @@ if (!skipAutoImports) {
|
|
|
347
428
|
await loadAutoImports()
|
|
348
429
|
|
|
349
430
|
// Run package auto-discovery after all imports are loaded
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
431
|
+
const actionsPackage = '@stacksjs/' + 'actions'
|
|
432
|
+
if (await belongsToThisProject(actionsPackage)) {
|
|
433
|
+
try {
|
|
434
|
+
const { discoverPackages } = await import(actionsPackage)
|
|
435
|
+
await discoverPackages()
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// Discovery may fail during early bootstrap — not critical
|
|
439
|
+
}
|
|
357
440
|
}
|
|
358
441
|
}
|
|
@@ -620,9 +620,9 @@ onDestroy(() => {
|
|
|
620
620
|
theme="macos"
|
|
621
621
|
width="250"
|
|
622
622
|
placement="fixed"
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
:
|
|
623
|
+
persistKey="stacks-dashboard-sidebar"
|
|
624
|
+
shellSelector="[data-stx-content]"
|
|
625
|
+
:followSystemAppearance="false"
|
|
626
626
|
@itemClick="handleSidebarItemClick($event)"
|
|
627
627
|
>
|
|
628
628
|
<template #header>
|
|
@@ -630,7 +630,7 @@ onDestroy(() => {
|
|
|
630
630
|
titlebarHidden), so this reserves their space and acts as the window
|
|
631
631
|
drag region; in a plain browser it is the same empty strip macOS
|
|
632
632
|
sidebars carry above their first row. -->
|
|
633
|
-
<SidebarHeader :
|
|
633
|
+
<SidebarHeader :showWindowControls="false" />
|
|
634
634
|
</template>
|
|
635
635
|
</Sidebar>
|
|
636
636
|
</div>
|