@stacksjs/defaults 0.75.267 → 0.75.269

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.
@@ -68,7 +68,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
68
68
  | `@stacksjs/orm` | [`stacks-orm`](../stacks-orm/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/orm/src/index.ts) | 72 |
69
69
  | `@stacksjs/pagination` | [`stacks-pagination`](../stacks-pagination/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/pagination/src/index.ts) | 0 |
70
70
  | `@stacksjs/path` | [`stacks-path`](../stacks-path/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/path/src/index.ts) | 13 |
71
- | `@stacksjs/payments` | [`stacks-payments`](../stacks-payments/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/index.ts) | 28 |
71
+ | `@stacksjs/payments` | [`stacks-payments`](../stacks-payments/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/index.ts) | 29 |
72
72
  | `@stacksjs/push` | [`stacks-push`](../stacks-push/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/push/src/index.ts) | 6 |
73
73
  | `@stacksjs/query-builder` | [`stacks-query-builder`](../stacks-query-builder/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/query-builder/src/index.ts) | 6 |
74
74
  | `@stacksjs/queue` | [`stacks-queue`](../stacks-queue/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/queue/src/index.ts) | 49 |
@@ -659,7 +659,7 @@ Declared runtime exports: `Payment`, `freshIdempotencyKey`, `stacksIdempotencyKe
659
659
 
660
660
  Local source modules: [src/billable/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/billable/index.ts), [src/checkout-outcome.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/checkout-outcome.ts), [src/connect/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/connect/index.ts), [src/direct-debit.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/direct-debit.ts), [src/driver/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/driver/index.ts), [src/drivers/stripe.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/drivers/stripe.ts), [src/idempotency.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/idempotency.ts), [src/payment.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/payment.ts), [src/subscription-state.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/subscription-state.ts), [src/terminal.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/src/terminal.ts).
661
661
 
662
- Evidence directory: [28 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
662
+ Evidence directory: [29 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/payments/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
663
663
 
664
664
  ### @stacksjs/push
665
665
 
@@ -133,6 +133,38 @@ const changed = await Payment.changeSubscription(user, 'enterprise-monthly')
133
133
 
134
134
  `Payment.connect` (source `src/connect/`) onboards merchants as Express connected accounts and settles sales to them, while the customer, the charge and its refunds and disputes stay on the platform.
135
135
 
136
+ ### Direct charges (Managed Risk, the default for new accounts)
137
+
138
+ `createConnectedAccount` makes a Managed Risk account unless you pass `type` or
139
+ `controller` (`MANAGED_RISK_CONTROLLER`: Stripe carries losses, full Stripe
140
+ Dashboard, Stripe-hosted onboarding). Stripe refuses `type: 'express'`/`'custom'`
141
+ on a platform that has not taken on loss liability, and such a platform sells
142
+ with **direct charges**: the sale is made on the merchant's account and the
143
+ platform's cut is an application fee. Everything of the sale lives there (the
144
+ customer, session, PaymentIntent, subscription and invoices, payment methods,
145
+ refund), so every call acts as the account with `asAccount(id)` (the
146
+ `Stripe-Account` header). Stripe's fees are the merchant's; webhooks for it
147
+ arrive on the **Connect** endpoint with `event.account` set.
148
+
149
+ ```typescript
150
+ import { asAccount, checkoutLineItemsTotal, connectedCustomer, connectRefund, directCheckout, directCheckoutPaymentData, directSubscriptionParams, manageDirectSubscription } from '@stacksjs/payments'
151
+
152
+ // One-off: Checkout on the account, the user's customer there found or created
153
+ await directCheckout(user, gym.stripe_account_id, {
154
+ line_items,
155
+ payment_intent_data: directCheckoutPaymentData(checkoutLineItemsTotal(line_items), { feeCents: 140 }, { metadata }),
156
+ success_url, cancel_url,
157
+ })
158
+ // Recurring: a subscription on the account paying the platform a percent of each invoice
159
+ await manageDirectSubscription.checkout(user, { account, price: { amount: 4900, currency: 'eur', interval: 'month', productName: 'Unlimited' }, platformFeePercent: 5, successUrl, cancelUrl })
160
+ await stripe.subscriptions.create({ customer: (await connectedCustomer(user, account)).id, items: [{ price }], ...directSubscriptionParams({ feePercent: 5 }) }, asAccount(account))
161
+ // Read, expire and refund as the account; a direct refund gives the fee back by default
162
+ await stripe.checkout.sessions.retrieve(sessionId, asAccount(account))
163
+ await connectRefund(paymentIntentId, { account, amount: 500 })
164
+ ```
165
+
166
+ ### Destination charges (a platform that took on loss liability)
167
+
136
168
  ```typescript
137
169
  import { manageConnectedAccount, manageDestinationCharge, manageDestinationSubscription } from '@stacksjs/payments'
138
170
 
@@ -162,9 +162,17 @@ export default defineModel({
162
162
  */
163
163
 
164
164
  /** Legacy owner column, written as a copy of `tokenable_id`. */
165
+ /**
166
+ * The legacy owner column, a copy of `tokenableId` kept for installs that
167
+ * predate the pair. `ensureAuthTables()` gives it no constraint, since a
168
+ * token can belong to an author as well as a user, so it is not a foreign
169
+ * key. Without saying so, convention inferred `users.id` from the name and
170
+ * a fresh app's first `buddy doctor` failed on it.
171
+ */
165
172
  userId: {
166
173
  order: 10,
167
174
  guarded: true,
175
+ foreignKey: false,
168
176
  validation: {
169
177
  rule: schema.number(),
170
178
  },
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ /**
4
+ * Run `parseDashboardDate` in a process pinned to `zone`, since a calendar
5
+ * date only goes wrong west of UTC and a test process's zone is fixed at
6
+ * start-up.
7
+ */
8
+ async function inZone(zone: string, expression: string): Promise<string> {
9
+ const child = Bun.spawn([process.execPath, '--no-env-file', '-e', `
10
+ const { parseDashboardDate } = await import(${JSON.stringify(`${import.meta.dir}/dashboard-dates.ts`)})
11
+ console.log(${expression})
12
+ `], { env: { ...process.env, TZ: zone }, stdout: 'pipe', stderr: 'pipe' })
13
+ const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
14
+ expect(code, err).toBe(0)
15
+ return out.trim()
16
+ }
17
+
18
+ const calendarDay = `[parseDashboardDate('2026-10-01')].map(d => [d.getFullYear(), d.getMonth() + 1, d.getDate()].join('-'))[0]`
19
+
20
+ describe('parseDashboardDate', () => {
21
+ for (const zone of ['America/Los_Angeles', 'UTC', 'Asia/Manila', 'Pacific/Kiritimati']) {
22
+ it(`reads a calendar date as that day in ${zone}`, async () => {
23
+ expect(await inZone(zone, calendarDay)).toBe('2026-10-1')
24
+ })
25
+ }
26
+
27
+ it('reads an offsetless database timestamp as UTC, wherever it runs', async () => {
28
+ for (const zone of ['America/Los_Angeles', 'Asia/Manila'])
29
+ expect(await inZone(zone, `parseDashboardDate('2026-10-01 14:30:00').toISOString()`)).toBe('2026-10-01T14:30:00.000Z')
30
+ })
31
+
32
+ it('leaves an ISO string with an offset, and a Date, as they are', async () => {
33
+ expect(await inZone('America/Los_Angeles', `parseDashboardDate('2026-10-01T14:30:00+08:00').toISOString()`)).toBe('2026-10-01T06:30:00.000Z')
34
+ const at = new Date('2026-10-01T00:00:00Z')
35
+ const { parseDashboardDate } = await import('./dashboard-dates')
36
+ expect(parseDashboardDate(at)).toBe(at)
37
+ })
38
+
39
+ it('the reading it replaces is the bug: a calendar date lands on the day before west of UTC', async () => {
40
+ // The control. Without it, every assertion above would pass in a zone
41
+ // where the two readings agree.
42
+ expect(await inZone('America/Los_Angeles', `[new Date('2026-10-01')].map(d => d.getDate())[0]`)).toBe('30')
43
+ })
44
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Read a date value from a dashboard row.
3
+ *
4
+ * A calendar date (`2026-10-01`) is a day, not an instant, so it is read as
5
+ * local midnight. `new Date('2026-10-01')` reads it as UTC midnight instead,
6
+ * which every zone west of UTC renders as the day before: a coupon starting
7
+ * on October 1st showed September 30th in Los Angeles.
8
+ *
9
+ * A database timestamp written without an offset (`2026-10-01 14:30:00`) is
10
+ * UTC, which is how the framework writes it, so it gains the `Z` it lacks.
11
+ * Anything else, ISO strings with an offset among them, is left to `Date`.
12
+ */
13
+ export function parseDashboardDate(value: unknown): Date {
14
+ if (value instanceof Date)
15
+ return value
16
+ const text = String(value)
17
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text))
18
+ return new Date(`${text}T00:00:00`)
19
+ if (/^\d{4}-\d{2}-\d{2} \d/.test(text))
20
+ return new Date(`${text.replace(' ', 'T')}Z`)
21
+ return new Date(text)
22
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.267",
5
+ "version": "0.75.269",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.267",
5
+ "version": "0.75.269",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -45,9 +45,9 @@
45
45
  "dependencies": {
46
46
  "@iconify-json/f7": "^1.2.2",
47
47
  "@iconify-json/hugeicons": "^1.2.35",
48
- "@stacksjs/mobile": "0.75.267",
49
- "@stacksjs/sanitizer": "0.2.664",
50
- "@stacksjs/stx": "0.2.664",
48
+ "@stacksjs/mobile": "0.75.269",
49
+ "@stacksjs/sanitizer": "0.2.667",
50
+ "@stacksjs/stx": "0.2.667",
51
51
  "@xterm/addon-fit": "^0.11.0",
52
52
  "@xterm/xterm": "^6.0.0",
53
53
  "ts-qr-codes": "^0.1.8"
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import { formatCurrency } from '@stacksjs/commerce/money'
3
+ import { parseDashboardDate } from '../../../../functions/dashboard-dates'
3
4
  /**
4
5
  * The data table.
5
6
  *
@@ -276,14 +277,14 @@ function displayValue(row: DashboardTableRow, column: DashboardTableColumn): str
276
277
  return Number.isFinite(amount) ? amount.toLocaleString() : String(value)
277
278
  }
278
279
  if (column.type === 'date') {
279
- const at = toDate(value)
280
+ const at = parseDashboardDate(value)
280
281
  return Number.isNaN(at.getTime()) ? String(value) : at.toLocaleDateString()
281
282
  }
282
283
  // An event or log row is ordered by when it happened, and to the minute, so
283
284
  // the date alone is not enough to read it. Separate from `date` rather than
284
285
  // a flag on it because the two render differently everywhere they appear.
285
286
  if (column.type === 'datetime') {
286
- const at = toDate(value)
287
+ const at = parseDashboardDate(value)
287
288
  return Number.isNaN(at.getTime())
288
289
  ? String(value)
289
290
  : at.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
@@ -346,20 +347,6 @@ function hasBadge(row: DashboardTableRow, column: DashboardTableColumn): boolean
346
347
  return badgeLabel(row, column) !== ''
347
348
  }
348
349
 
349
- /**
350
- * Parse a stored timestamp.
351
- *
352
- * SQLite hands back `2026-01-01 12:00:00`, with a space and no zone. `new
353
- * Date()` reads that as local time where it reads it at all, so every row
354
- * shifted by the viewer's offset - a date near midnight landing on the wrong
355
- * day. The hand-rolled tables each normalised it themselves; this is the same
356
- * knowledge in the one place that needs it.
357
- */
358
- function toDate(value: unknown): Date {
359
- const text = String(value)
360
- return new Date(/^\d{4}-\d{2}-\d{2} \d/.test(text) ? `${text.replace(' ', 'T')}Z` : text)
361
- }
362
-
363
350
  /** The pill's text: the mapped label, else the value with its underscores read as spaces. */
364
351
  function badgeLabel(row: DashboardTableRow, column: DashboardTableColumn): string {
365
352
  const value = rawValue(row, column.key)
@@ -397,7 +384,7 @@ function compare(a: any, b: any, type: DashboardTableColumn['type']): number {
397
384
  // comparison below, which is only accidentally right for ISO timestamps and
398
385
  // wrong for every other format.
399
386
  if (type === 'date' || type === 'datetime')
400
- return toDate(a).getTime() - toDate(b).getTime()
387
+ return parseDashboardDate(a).getTime() - parseDashboardDate(b).getTime()
401
388
  if (type === 'boolean')
402
389
  return (a ? 1 : 0) - (b ? 1 : 0)
403
390