@stacksjs/defaults 0.75.76 → 0.75.77

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 (34) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  6. package/ai/skills/stacks-models/SKILL.md +2 -2
  7. package/ai/skills/stacks-orm/SKILL.md +7 -12
  8. package/ai/skills/stacks-payments/SKILL.md +22 -7
  9. package/ai/skills/stacks-types/SKILL.md +1 -1
  10. package/app/Actions/Dashboard/Settings/BillingShowAction.ts +6 -2
  11. package/app/Actions/Payment/CancelSubscriptionAction.ts +5 -6
  12. package/app/Actions/Payment/CreateCheckoutAction.ts +34 -13
  13. package/app/Actions/Payment/CreateInvoiceSubscription.ts +27 -9
  14. package/app/Actions/Payment/CreatePaymentIntentAction.ts +23 -10
  15. package/app/Actions/Payment/CreateSetupIntentAction.ts +9 -5
  16. package/app/Actions/Payment/CreateSubscriptionAction.ts +13 -7
  17. package/app/Actions/Payment/DeleteDefaultPaymentAction.ts +12 -2
  18. package/app/Actions/Payment/FetchActiveSubscriptionAction.ts +8 -3
  19. package/app/Actions/Payment/FetchPaymentCustomerAction.ts +9 -5
  20. package/app/Actions/Payment/FetchPaymentMethodsAction.ts +7 -3
  21. package/app/Actions/Payment/SetDefaultPaymentAction.ts +14 -7
  22. package/app/Actions/Payment/SetUserDefaultPaymentAction.ts +14 -6
  23. package/app/Actions/Payment/StorePaymentMethodAction.ts +7 -3
  24. package/app/Actions/Payment/UpdateDefaultPaymentMethodAction.ts +13 -2
  25. package/app/Actions/Payment/UpdateSubscriptionAction.ts +11 -6
  26. package/app/Actions/Payment/payment-response.ts +50 -0
  27. package/app/Models/commerce/Payment.ts +10 -0
  28. package/app/Models/commerce/PaymentWebhookEvent.ts +56 -0
  29. package/functions/billing/dashboard.ts +11 -7
  30. package/functions/remote-terminal-element.ts +3 -0
  31. package/functions/text-import-declarations.test.ts +48 -0
  32. package/ide/vscode/package.json +1 -1
  33. package/package.json +2 -2
  34. package/stores/payment.ts +25 -15
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 657 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 658 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (104 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (105 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Commerce
10
10
 
11
- Comprehensive e-commerce module with 15 sub-modules and 36 models.
11
+ Comprehensive e-commerce module with 15 sub-modules and 37 models.
12
12
 
13
13
  ## Key Paths
14
14
  - Core package: `storage/framework/core/commerce/src/`
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (233 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (235 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 233 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 235 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 233 migration files exist by default for all framework models
122
+ - 235 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-models
3
- description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 104 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
3
+ description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 105 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -304,7 +304,7 @@ honoured: fewer rows are seeded rather than a duplicate combination.
304
304
  ## Built-in models by category
305
305
 
306
306
  The ones below are worth knowing by name. They are a selection, not the set:
307
- `storage/framework/defaults/app/Models/` holds 104, and that directory is the
307
+ `storage/framework/defaults/app/Models/` holds 105, and that directory is the
308
308
  authority. This section said "All 62 built-in models by category" while
309
309
  listing fewer than that against 102 on disk, so an agent reading to the end
310
310
  had no way to tell it was short.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-orm
3
- description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 104 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
3
+ description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 105 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (104 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (105 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -239,16 +239,11 @@ Table defaults to `{tableName}_likes`, FK defaults to `{singular}_id`.
239
239
  - `Model._likeable.unlike(id, userId): Promise<void>`
240
240
  - `Model._likeable.isLiked(id, userId): Promise<boolean>`
241
241
 
242
- ### Billable (when `traits.billable: true`) -- Stripe integration
243
- All methods lazy-import `@stacksjs/payments`.
244
- - `createStripeUser(model, options)`, `updateStripeUser(model, options)`, `deleteStripeUser(model)`
245
- - `createOrGetStripeUser(model, options)`, `retrieveStripeUser(model)`
246
- - `defaultPaymentMethod(model)`, `setDefaultPaymentMethod(model, pmId)`, `addPaymentMethod(model, paymentMethodId)`, `paymentMethods(model, cardType?)`
247
- - `newSubscription(model, type, lookupKey, options)` -- returns `{ subscription, paymentIntent }`
248
- - `updateSubscription(model, type, lookupKey, options)`, `cancelSubscription(model, providerId, options)`
249
- - `activeSubscription(model)` -- queries `subscriptions` table for `provider_status = 'active'`, then retrieves from Stripe
250
- - `checkout(model, priceIds[], options)` -- supports `enableTax`, `allowPromotions` options
251
- - `createSetupIntent(model, options)`, `subscriptionHistory(model)`, `transactionHistory(model)`
242
+ ### Billable (when `traits.billable: true`) -- the configured payment driver
243
+ All methods lazy-import `@stacksjs/payments`. Full reference: `stacks-payments`.
244
+ - Provider-neutral (`config.payment.driver`): `paymentCustomer(model)`, `charge(model, money, paymentMethodId, options)`, `createPayment(model, money, options)`, `checkout(model, { mode, lines, successUrl, cancelUrl })`, `paymentMethods(model)`, `removePaymentMethod(model, providerId)`, `newSubscription(model, type, price)` -> `SubscriptionSummary`, `cancelSubscription(model, providerId, { atPeriodEnd })` (ownership-checked), `activeSubscription(model)` -> `{ subscription, providerSubscription }`
245
+ - Stripe only (throw `PaymentUnsupportedError` under another driver): `createStripeUser`, `updateStripeUser`, `deleteStripeUser`, `createOrGetStripeUser`, `retrieveStripeUser`, `syncStripeCustomerDetails`, `setDefaultPaymentMethod`, `addPaymentMethod`, `updateSubscription`, `createSetupIntent`, `subscriptionHistory`, Connect methods
246
+ - Local: `defaultPaymentMethod(model)`, `storeTransaction(model, productId, options)`, `transactionHistory(model)`
252
247
 
253
248
  ### Two-Factor Auth (when `traits.useAuth.useTwoFactor: true`)
254
249
  - `Model._twoFactor.generateTwoFactorForModel(model)` -- generates secret, calls `model.update()`
@@ -18,7 +18,9 @@ Full Stripe integration via the Payment facade. Uses Stripe API version `2026-01
18
18
  - Methods: `customer`, `charge(payer, money, paymentMethodId)`, `createPayment` (browser completes: `clientConfirmation` is a Stripe client secret or an Adyen session), `refund`, `checkout(payer, { mode, lines, successUrl, ... })`, `paymentMethods`, `removePaymentMethod`, `subscribe`, `cancelSubscription`, `subscriptions`, `verifyWebhook({ payload, headers })` -> `PaymentEvent[]`, `acknowledgeWebhook()`.
19
19
  - Unsupported operations throw `PaymentUnsupportedError` (driver + operation). Adyen has no catalog, subscriptions, invoices, coupons or portal.
20
20
  - Adyen: Checkout API v72, `X-API-Key`; shopper reference `user-<id>` (no PII); full refunds go to `/reversals`; webhooks verified per item with HMAC-SHA256 over 8 colon-joined fields under the hex key, acknowledged with 202.
21
+ - `PaymentEvent` carries `provider` and `id` (together, the retry key), `reference` (the payment; for a refund, the payment refunded), `amount` (for a refund, that one refund - Stripe's cumulative `amount_refunded` is turned into a delta from `previous_attributes`) and `reason`. Adyen's id is `eventCode:pspReference:success`, since Adyen may resend a pair with the outcome changed.
21
22
  - The Stripe driver delegates to the existing billable modules, so idempotency keys and `stripe_id` handling are unchanged.
23
+ - Commerce: `orders.handleCommercePaymentEvent(event)` from `@stacksjs/commerce` applies `payment.succeeded` / `payment.failed` (reason into `payments.failure_reason`) / `refund.succeeded` (added to `refund_amount`; `refunded` + order REFUNDED only when it covers the payment, else `partiallyRefunded`) to the order, deduplicated in `payment_webhook_events` (the `PaymentWebhookEvent` model) in the same transaction.
22
24
  - Everything below this section is Stripe's own API and returns Stripe objects.
23
25
 
24
26
  ## Key Paths
@@ -487,6 +489,7 @@ for the plans it is meant for.
487
489
  - `payment_methods` -- columns: `id`, `type`, `last_four`, `brand`, `exp_year`, `exp_month`, `user_id`, `provider_id`, `is_default`
488
490
  - `payment_products` -- columns: `id`, `name`, `unit_price`
489
491
  - `payment_transactions` -- columns: `id`, `name`, `description`, `amount`, `brand`, `type`, `provider_id`, `user_id`
492
+ - `payment_webhook_events` -- columns: `provider`, `event_id` (unique together), `processed_at`; commerce's webhook dedup
490
493
 
491
494
  ## User Model Requirements
492
495
  The `UserModel` must have:
@@ -521,12 +524,24 @@ const customer = await user.retrieveStripeUser()
521
524
  ```
522
525
 
523
526
  The instance methods are exactly the keys of `createBillableMethods` in
524
- `core/orm/src/traits/billable.ts`. Among them: `retrieveStripeUser()`,
525
- `createPayment(amount, options)`, `setDefaultPaymentMethod(id)` (a number is the
526
- local row, a string is Stripe's `pm_...` id), `deletePaymentMethod(id)`,
527
- `syncStripeCustomerDetails(options)`, `storeTransaction(productId, options)`,
528
- `newSubscription(type, lookupKey, options)`. Anything else is not a function at
529
- runtime.
527
+ `core/orm/src/traits/billable.ts`. Anything else is not a function at runtime.
528
+
529
+ - Provider-neutral, through the configured driver: `paymentCustomer()`,
530
+ `charge(money, paymentMethodId, options)`, `createPayment(money, options)`,
531
+ `checkout({ mode, lines, successUrl, cancelUrl })`, `paymentMethods()`,
532
+ `removePaymentMethod(providerId)`, `newSubscription(type, price)`,
533
+ `cancelSubscription(providerId, { atPeriodEnd })` (refuses a subscription the
534
+ record does not own), `activeSubscription()`. Money is `{ amount, currency }`
535
+ in minor units; results are the driver's shapes, with `raw` holding the
536
+ provider's object (strip it before sending to a browser).
537
+ - Stripe only, throwing `PaymentUnsupportedError` under another driver:
538
+ `createStripeUser`, `updateStripeUser`, `deleteStripeUser`,
539
+ `createOrGetStripeUser`, `retrieveStripeUser`, `syncStripeCustomerDetails`,
540
+ `setDefaultPaymentMethod(id)` (a number is the local row, a string Stripe's
541
+ `pm_...` id), `addPaymentMethod`, `updateSubscription`, `createSetupIntent`,
542
+ `subscriptionHistory`, and the Connect methods.
543
+ - Local only: `defaultPaymentMethod()`, `storeTransaction(productId, options)`,
544
+ `transactionHistory()`.
530
545
 
531
546
  ## Gotchas
532
547
  - Stripe API keys MUST be in `.env` as `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` -- never hardcode them in config files
@@ -534,7 +549,7 @@ runtime.
534
549
  - All amounts are in cents -- use `toCents()` and `toDollars()` for conversion
535
550
  - `charge()` creates AND confirms the PaymentIntent in one step
536
551
  - `subscribe()` resolves the price via `lookup_key`, not a direct Stripe price ID
537
- - `removePaymentMethod()` takes a database record ID (number), not a Stripe PM ID (string)
552
+ - The facade's `Payment.removePaymentMethod()` takes a database record ID (number); the billable instance method `removePaymentMethod()` takes the provider's id (string)
538
553
  - `setDefaultPaymentMethod` has two variants: one takes a Stripe PM ID string (`setUserDefaultPayment`), the other takes a database ID number
539
554
  - `getOrCreateCustomer()` handles deleted Stripe customers by recreating them
540
555
  - Subscription status checks query the local database, not Stripe directly
@@ -86,7 +86,7 @@ All model CRUD events: `model:created`, `model:updated`, `model:deleted`
86
86
 
87
87
  - **Content**: author, post, page
88
88
  - **Core**: user, activity, campaign, comment, email-list, notification, social-post, subscription, tag
89
- - **Commerce (36 models)**: cart, cart-item, category, coupon, customer, order, order-item, payment, product, product-variant, review, shipping-method, shipping-rate, tax-rate, transaction, gift-card, license-key, and more
89
+ - **Commerce (37 models)**: cart, cart-item, category, coupon, customer, order, order-item, payment, product, product-variant, review, shipping-method, shipping-rate, tax-rate, transaction, gift-card, license-key, and more
90
90
 
91
91
  All payloads are `Record<string, any>`.
92
92
 
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { PaymentTransaction, User } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser } from '../../Payment/payment-response'
5
6
 
6
7
  interface SettledBillingRead {
7
8
  label: string
@@ -82,9 +83,12 @@ export default new Action({
82
83
  { label: 'Default payment method', result: defaultPaymentMethod },
83
84
  ]
84
85
 
86
+ // The provider's own objects stay on the server; the page reads the
87
+ // provider-neutral fields beside them.
88
+ const active = subscription.status === 'fulfilled' ? subscription.value as { subscription?: unknown, providerSubscription?: unknown } | undefined : undefined
85
89
  return {
86
- subscription: subscription.status === 'fulfilled' ? subscription.value : null,
87
- paymentMethods: paymentMethods.status === 'fulfilled' ? paymentMethods.value : [],
90
+ subscription: active ? { subscription: active.subscription, providerSubscription: forBrowser(active.providerSubscription) } : null,
91
+ paymentMethods: paymentMethods.status === 'fulfilled' ? forBrowser(paymentMethods.value as unknown[]) : [],
88
92
  defaultPaymentMethod: defaultPaymentMethod.status === 'fulfilled' ? defaultPaymentMethod.value : null,
89
93
  transactions: transactions.map(serializeBillingTransaction),
90
94
  unavailable: billingReadFailures(providerReads),
@@ -2,10 +2,11 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable, SubscriptionNotOwnedError } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'CancelSubscriptionAction',
8
- description: 'Cancel Subscription for stripe',
9
+ description: 'Cancel the caller\'s subscription, now or at the end of its period',
9
10
  method: 'POST',
10
11
  async handle(request: RequestInstance) {
11
12
  const providerId = request.get<unknown>('providerId')
@@ -24,15 +25,13 @@ export default new Action({
24
25
  // Only the caller's own subscription: the trait checks ownership, and one
25
26
  // 404 covers both "no such subscription" and "someone else's".
26
27
  try {
27
- // `cancelSubscription` answers `{ subscription }` and nothing else. This
28
- // returned `.paymentIntent` off it, which is always undefined.
29
- const { subscription } = await user.cancelSubscription(providerId)
30
- return response.json(subscription)
28
+ const subscription = await user.cancelSubscription(providerId, { atPeriodEnd: request.get('atPeriodEnd') === true })
29
+ return response.json(forBrowser(subscription))
31
30
  }
32
31
  catch (error) {
33
32
  if (error instanceof SubscriptionNotOwnedError)
34
33
  return response.json({ error: 'Subscription not found' }, 404)
35
- throw error
34
+ return paymentFailure(error)
36
35
  }
37
36
  },
38
37
  })
@@ -2,10 +2,19 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
7
+ /**
8
+ * A hosted checkout for a price, answered with the page to send the payer to.
9
+ *
10
+ * It used to check out a hard-coded Stripe price id from someone's account
11
+ * and return the payer to https://google.com, which the same-origin check on
12
+ * checkout redirects refused anyway. The price and the return pages come from
13
+ * the request now, and the return pages must be on this app's origin.
14
+ */
6
15
  export default new Action({
7
16
  name: 'CreateCheckoutAction',
8
- description: 'Create Checkout link for stripe',
17
+ description: 'Create a hosted checkout page for a price',
9
18
  method: 'POST',
10
19
  async handle(request: RequestInstance) {
11
20
  const user = await request.user()
@@ -16,18 +25,30 @@ export default new Action({
16
25
  if (!isBillable(user))
17
26
  return response.error(BILLING_NOT_ENABLED, 503)
18
27
 
19
- const checkout = await user.checkout([
20
- {
21
- priceId: 'price_1QBEfsBv6MhUdo23avVV0kqx',
22
- quantity: 1,
23
- },
24
- ], {
25
- enableTax: true,
26
- allowPromotions: true,
27
- cancel_url: 'https://google.com',
28
- success_url: 'https://google.com',
29
- })
28
+ const price = request.get('price')
29
+ const successUrl = request.get('successUrl')
30
+ const cancelUrl = request.get('cancelUrl')
31
+ const mode = request.get('mode') === 'subscription' ? 'subscription' : 'payment'
32
+ const quantity = Number(request.get('quantity') ?? 1)
30
33
 
31
- return response.json(checkout)
34
+ if (typeof price !== 'string' || !price)
35
+ return response.json({ message: 'A `price` is required.' }, 422)
36
+ if (typeof successUrl !== 'string' || !successUrl)
37
+ return response.json({ message: 'A `successUrl` is required.' }, 422)
38
+ if (!Number.isInteger(quantity) || quantity < 1)
39
+ return response.json({ message: '`quantity` is a whole number of at least 1.' }, 422)
40
+
41
+ try {
42
+ const session = await user.checkout({
43
+ mode,
44
+ lines: [{ price, quantity }],
45
+ successUrl,
46
+ ...(typeof cancelUrl === 'string' && cancelUrl ? { cancelUrl } : {}),
47
+ })
48
+ return response.json(forBrowser(session))
49
+ }
50
+ catch (error) {
51
+ return paymentFailure(error)
52
+ }
32
53
  },
33
54
  })
@@ -1,7 +1,10 @@
1
+ import type { UserModel } from '@stacksjs/orm'
1
2
  import { Action } from '@stacksjs/actions/runtime'
3
+ import { config } from '@stacksjs/config'
2
4
  import { isBillable } from '@stacksjs/orm'
3
- import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
5
+ import { BILLING_NOT_ENABLED, manageSubscription, PaymentUnsupportedError, summarizeSubscription } from '@stacksjs/payments'
4
6
  import { response } from '@stacksjs/router'
7
+ import { forBrowser, paymentFailure } from './payment-response'
5
8
 
6
9
  export default new Action({
7
10
  name: 'CreateInvoiceSubscription',
@@ -16,14 +19,29 @@ export default new Action({
16
19
  if (!isBillable(user))
17
20
  return response.error(BILLING_NOT_ENABLED, 503)
18
21
 
19
- // There is no `newSubscriptionInvoice` on a billable user, and never was, so
20
- // this threw "is not a function". An invoiced subscription is an ordinary
21
- // one that Stripe bills by emailing an invoice instead of charging a card.
22
- const subscription = await user.newSubscription('pro', 'stacks_pro_monthly', {
23
- collection_method: 'send_invoice',
24
- days_until_due: 30,
25
- })
22
+ const type = request.get('type')
23
+ const plan = request.get('plan')
24
+ if (typeof type !== 'string' || !type || typeof plan !== 'string' || !plan)
25
+ return response.json({ message: 'An invoiced subscription needs a `plan` (its name) and a `type` (the price lookup key).' }, 422)
26
26
 
27
- return response.json(subscription)
27
+ // An invoiced subscription is an ordinary one that Stripe bills by emailing
28
+ // an invoice instead of charging a card - a Stripe Billing feature. It used
29
+ // to subscribe everyone to a hard-coded 'pro' / 'stacks_pro_monthly'.
30
+ try {
31
+ const driver = String((config as { payment?: { driver?: string } }).payment?.driver ?? 'stripe')
32
+ if (driver !== 'stripe')
33
+ throw new PaymentUnsupportedError(driver, 'invoiced subscriptions', 'they are a Stripe Billing feature')
34
+
35
+ // `request.user()` is the User model instance, which is what the
36
+ // billable modules take; its request-facing type omits the model's own methods.
37
+ const subscription = await manageSubscription.create(user as unknown as UserModel, plan, type, {
38
+ collection_method: 'send_invoice',
39
+ days_until_due: 30,
40
+ })
41
+ return response.json(forBrowser(summarizeSubscription(subscription)))
42
+ }
43
+ catch (error) {
44
+ return paymentFailure(error)
45
+ }
28
46
  },
29
47
  })
@@ -1,12 +1,19 @@
1
1
  import { Action } from '@stacksjs/actions/runtime'
2
+ import { config } from '@stacksjs/config'
2
3
  import { HttpError } from '@stacksjs/error-handling'
3
4
  import { isBillable } from '@stacksjs/orm'
4
5
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
5
6
  import { response } from '@stacksjs/router'
7
+ import { forBrowser, paymentFailure } from './payment-response'
6
8
 
9
+ /**
10
+ * Start a payment for a product, completed in the browser on the provider's
11
+ * SDK. Answers the payment with its `clientConfirmation`: a Stripe client
12
+ * secret, or an Adyen session (which also needs `returnUrl`).
13
+ */
7
14
  export default new Action({
8
15
  name: 'CreatePaymentIntentAction',
9
- description: 'Create Payment Intent for stripe',
16
+ description: 'Start a payment for a product, completed in the browser',
10
17
  method: 'POST',
11
18
  async handle(request: RequestInstance) {
12
19
  const productId = Number(request.get('productId'))
@@ -25,14 +32,20 @@ export default new Action({
25
32
  throw new HttpError(422, 'Product not found!')
26
33
  }
27
34
 
28
- // The Product model's column is `price`; `unit_price` has never been one,
29
- // so that read was undefined and the intent was created for NaN. And the
30
- // method is `createPayment`: `user.paymentIntent()` never existed.
31
- const paymentIntent = await user.createPayment(Number(product.get('price')), {
32
- currency: 'usd',
33
- allowed_payment_method_types: ['card'],
34
- })
35
-
36
- return response.json(paymentIntent)
35
+ // The currency the app configures, not a hard-coded USD; the product's
36
+ // price is already in minor units.
37
+ const currency = String((config as { payment?: { currency?: string } }).payment?.currency || 'usd')
38
+ const returnUrl = request.get('returnUrl')
39
+
40
+ try {
41
+ const payment = await user.createPayment(
42
+ { amount: Number(product.get('price')), currency },
43
+ { reference: `product-${productId}`, ...(typeof returnUrl === 'string' && returnUrl ? { returnUrl } : {}) },
44
+ )
45
+ return response.json(forBrowser(payment))
46
+ }
47
+ catch (error) {
48
+ return paymentFailure(error)
49
+ }
37
50
  },
38
51
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'CreateSetupIntentAction',
@@ -16,10 +17,13 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const setupIntent = await user.createSetupIntent({
20
- allowed_payment_method_types: ['card', 'link', 'us_bank_account'],
21
- })
22
-
23
- return response.json(setupIntent)
20
+ // `allowed_payment_method_types` was passed here, which is not a Stripe
21
+ // parameter; Stripe chooses the methods from the dashboard settings.
22
+ try {
23
+ return response.json(await user.createSetupIntent())
24
+ }
25
+ catch (error) {
26
+ return paymentFailure(error)
27
+ }
24
28
  },
25
29
  })
@@ -2,16 +2,17 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'CreateSubscriptionAction',
8
9
  description: 'Create Subscription for stripe',
9
10
  method: 'POST',
10
11
  async handle(request: RequestInstance) {
11
- const type = request.get('type') as string
12
- const plan = request.get('plan') as string
13
- const period = request.get('period') as string
14
- const description = request.get('description') as string
12
+ const type = request.get('type')
13
+ const plan = request.get('plan')
14
+ if (typeof type !== 'string' || !type || typeof plan !== 'string' || !plan)
15
+ return response.json({ message: 'A subscription needs a `plan` (its name) and a `type` (the price lookup key).' }, 422)
15
16
 
16
17
  const user = await request.user()
17
18
 
@@ -23,8 +24,13 @@ export default new Action({
23
24
 
24
25
  // `plan` is the subscription's name ('pro') and `type` the price lookup key
25
26
  // ('stacks_pro_monthly'), which is the order `newSubscription` takes them in.
26
- const { paymentIntent } = await user.newSubscription(plan, type, { description, metadata: { period } })
27
-
28
- return response.json(paymentIntent)
27
+ // An incomplete subscription carries a `clientConfirmation` for its first
28
+ // payment, which the browser confirms.
29
+ try {
30
+ return response.json(forBrowser(await user.newSubscription(plan, type)))
31
+ }
32
+ catch (error) {
33
+ return paymentFailure(error)
34
+ }
29
35
  },
30
36
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'DeleteDefaultPaymentAction',
@@ -16,8 +17,17 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const paymentMethod = Number(request.get('paymentMethod'))
20
+ // The provider's id for the method, as `paymentMethods` lists it.
21
+ const paymentMethod = request.get('paymentMethod')
22
+ if (typeof paymentMethod !== 'string' || !paymentMethod)
23
+ return response.json({ message: 'A `paymentMethod` id is required.' }, 422)
20
24
 
21
- await user.deletePaymentMethod(paymentMethod)
25
+ try {
26
+ await user.removePaymentMethod(paymentMethod)
27
+ return response.json({ ok: true })
28
+ }
29
+ catch (error) {
30
+ return paymentFailure(error)
31
+ }
22
32
  },
23
33
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'FetchActiveSubscriptionAction',
@@ -16,8 +17,12 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const subscription = await user.activeSubscription()
20
-
21
- return response.json(subscription)
20
+ try {
21
+ const active = await user.activeSubscription()
22
+ return response.json(active ? { subscription: active.subscription, providerSubscription: forBrowser(active.providerSubscription) } : null)
23
+ }
24
+ catch (error) {
25
+ return paymentFailure(error)
26
+ }
22
27
  },
23
28
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'FetchPaymentCustomerAction',
@@ -16,10 +17,13 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- // `asStripeUser()` does not exist; `retrieveStripeUser()` is the lookup,
20
- // and answers undefined for a user with no Stripe customer yet.
21
- const customer = await user.retrieveStripeUser()
22
-
23
- return response.json(customer)
20
+ // The provider's customer for this user - created on first use - in our
21
+ // terms, whichever provider is configured.
22
+ try {
23
+ return response.json(await user.paymentCustomer())
24
+ }
25
+ catch (error) {
26
+ return paymentFailure(error)
27
+ }
24
28
  },
25
29
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'FetchPaymentMethodsAction',
@@ -16,8 +17,11 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const paymentMethods = await user.paymentMethods()
20
-
21
- return response.json(paymentMethods)
20
+ try {
21
+ return response.json(forBrowser(await user.paymentMethods()))
22
+ }
23
+ catch (error) {
24
+ return paymentFailure(error)
25
+ }
22
26
  },
23
27
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { paymentFailure, paymentMethodReference } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'SetDefaultPaymentAction',
@@ -16,13 +17,19 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- // The payment store posts `{ paymentId }`; this read only `setupIntent`, so
20
- // it saw undefined and asked for payment method NaN. `setupIntent` is still
21
- // read for any client written against the old name.
22
- const paymentId = Number(request.get('paymentId') ?? request.get('setupIntent'))
20
+ // The payment store posts `{ paymentId }`; `setupIntent` is still read for
21
+ // a client written against the old name. Either a local row id or the
22
+ // provider's own `pm_...` id.
23
+ const paymentMethod = paymentMethodReference(request.get('paymentId') ?? request.get('setupIntent'))
24
+ if (paymentMethod === null)
25
+ return response.json({ message: 'A `paymentId` is required.' }, 422)
23
26
 
24
- const paymentMethod = await user.setDefaultPaymentMethod(paymentId)
25
-
26
- return response.json(paymentMethod)
27
+ try {
28
+ await user.setDefaultPaymentMethod(paymentMethod)
29
+ return response.json({ ok: true })
30
+ }
31
+ catch (error) {
32
+ return paymentFailure(error)
33
+ }
27
34
  },
28
35
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { paymentFailure, paymentMethodReference } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'SetUserDefaultPaymentAction',
@@ -16,12 +17,19 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const paymentId = String(request.get('setupIntent'))
20
+ // The payment store posts `{ paymentId }`; `setupIntent` is still read for
21
+ // a client written against the old name. Either a local row id or the
22
+ // provider's own `pm_...` id.
23
+ const paymentMethod = paymentMethodReference(request.get('setupIntent') ?? request.get('paymentId'))
24
+ if (paymentMethod === null)
25
+ return response.json({ message: 'A `paymentId` is required.' }, 422)
20
26
 
21
- // A string id is Stripe's own `pm_...`, which `setDefaultPaymentMethod`
22
- // resolves on Stripe's side. `setUserDefaultPaymentMethod()` never existed.
23
- const paymentMethod = await user.setDefaultPaymentMethod(paymentId)
24
-
25
- return response.json(paymentMethod)
27
+ try {
28
+ await user.setDefaultPaymentMethod(paymentMethod)
29
+ return response.json({ ok: true })
30
+ }
31
+ catch (error) {
32
+ return paymentFailure(error)
33
+ }
26
34
  },
27
35
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'StorePaymentMethodAction',
@@ -18,8 +19,11 @@ export default new Action({
18
19
 
19
20
  const paymentIntent = request.get('setupIntent') as string
20
21
 
21
- const paymentMethod = await user.addPaymentMethod(paymentIntent)
22
-
23
- return response.json(paymentMethod)
22
+ try {
23
+ return response.json(forBrowser(await user.addPaymentMethod(paymentIntent)))
24
+ }
25
+ catch (error) {
26
+ return paymentFailure(error)
27
+ }
24
28
  },
25
29
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { paymentFailure, paymentMethodReference } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'UpdateDefaultPaymentMethodAction',
@@ -16,8 +17,18 @@ export default new Action({
16
17
  if (!isBillable(user))
17
18
  return response.error(BILLING_NOT_ENABLED, 503)
18
19
 
19
- const paymentMethod = Number(request.get('paymentMethod'))
20
+ // A local row id or the provider's own id. This answered nothing at all,
21
+ // so the client waited on an empty response.
22
+ const paymentMethod = paymentMethodReference(request.get('paymentMethod'))
23
+ if (paymentMethod === null)
24
+ return response.json({ message: 'A `paymentMethod` is required.' }, 422)
20
25
 
21
- await user.setDefaultPaymentMethod(paymentMethod)
26
+ try {
27
+ await user.setDefaultPaymentMethod(paymentMethod)
28
+ return response.json({ ok: true })
29
+ }
30
+ catch (error) {
31
+ return paymentFailure(error)
32
+ }
22
33
  },
23
34
  })
@@ -2,15 +2,17 @@ import { Action } from '@stacksjs/actions/runtime'
2
2
  import { isBillable } from '@stacksjs/orm'
3
3
  import { BILLING_NOT_ENABLED } from '@stacksjs/payments'
4
4
  import { response } from '@stacksjs/router'
5
+ import { forBrowser, paymentFailure } from './payment-response'
5
6
 
6
7
  export default new Action({
7
8
  name: 'UpdateSubscriptionAction',
8
9
  description: 'Update Subscription for stripe',
9
10
  method: 'POST',
10
11
  async handle(request: RequestInstance) {
11
- const type = request.get('type') as string
12
- const plan = request.get('plan') as string
13
- const description = request.get('description') as string
12
+ const type = request.get('type')
13
+ const plan = request.get('plan')
14
+ if (typeof type !== 'string' || !type || typeof plan !== 'string' || !plan)
15
+ return response.json({ message: 'A subscription change needs a `plan` and a `type` (the new price lookup key).' }, 422)
14
16
 
15
17
  const user = await request.user()
16
18
 
@@ -20,8 +22,11 @@ export default new Action({
20
22
  if (!isBillable(user))
21
23
  return response.error(BILLING_NOT_ENABLED, 503)
22
24
 
23
- const { paymentIntent } = await user.updateSubscription(plan, type, { description })
24
-
25
- return response.json(paymentIntent)
25
+ try {
26
+ return response.json(forBrowser(await user.updateSubscription(plan, type)))
27
+ }
28
+ catch (error) {
29
+ return paymentFailure(error)
30
+ }
26
31
  },
27
32
  })
@@ -0,0 +1,50 @@
1
+ import { PaymentProviderError, PaymentUnsupportedError } from '@stacksjs/payments'
2
+ import { response } from '@stacksjs/router'
3
+
4
+ /**
5
+ * What the billing endpoints answer when a payment call fails in a way the
6
+ * caller can act on, so every endpoint reports it the same way:
7
+ *
8
+ * - the configured provider cannot do it: 501, naming the provider;
9
+ * - the provider refused it: 502, with the provider's message and code;
10
+ * - the request itself was wrong (a float amount, a URL off this origin): 422.
11
+ *
12
+ * Anything else is a bug and is rethrown to the error handler.
13
+ */
14
+ export function paymentFailure(error: unknown): Response {
15
+ if (error instanceof PaymentUnsupportedError)
16
+ return response.json({ message: error.message, driver: error.driver, operation: error.operation }, 501)
17
+ if (error instanceof PaymentProviderError)
18
+ return response.json({ message: error.message, driver: error.driver, code: error.code }, 502)
19
+ if (error instanceof TypeError)
20
+ return response.json({ message: error.message }, 422)
21
+ throw error
22
+ }
23
+
24
+ /**
25
+ * A driver result as the browser should see it: without `raw`, the provider's
26
+ * own object, which carries far more than the page needs - customer ids,
27
+ * metadata, other parties' references.
28
+ */
29
+ export function forBrowser<T>(value: T): T {
30
+ if (Array.isArray(value))
31
+ return value.map(entry => forBrowser(entry)) as T
32
+ if (value && typeof value === 'object' && 'raw' in value) {
33
+ const { raw: _raw, ...rest } = value as Record<string, unknown>
34
+ return rest as T
35
+ }
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * A payment method reference from a request: a local `payment_methods` row id
41
+ * (a number, or digits) or the provider's own id (`pm_...`). Converting every
42
+ * value with `Number()` turned a provider id into NaN.
43
+ */
44
+ export function paymentMethodReference(value: unknown): number | string | null {
45
+ if (typeof value === 'number' && Number.isInteger(value))
46
+ return value
47
+ if (typeof value !== 'string' || !value)
48
+ return null
49
+ return /^\d+$/.test(value) ? Number(value) : value
50
+ }
@@ -184,6 +184,16 @@ export default defineModel({
184
184
  },
185
185
  factory: faker => faker.helpers.maybe(() => faker.lorem.sentence(), { probability: 0.3 }),
186
186
  },
187
+
188
+ // Why the provider declined it, in the provider's words. Written by the
189
+ // payment webhook (`handleCommercePaymentEvent`) for ops triage.
190
+ failureReason: {
191
+ order: 16,
192
+ fillable: true,
193
+ validation: {
194
+ rule: schema.string().max(1000),
195
+ },
196
+ },
187
197
  },
188
198
 
189
199
  dashboard: {
@@ -0,0 +1,56 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation/runtime'
3
+
4
+ /**
5
+ * A payment webhook delivery already applied. Providers retry until they are
6
+ * answered, so commerce records each event here in the same transaction as the
7
+ * order update it makes, and a retry finds its row and does nothing.
8
+ */
9
+ export default defineModel({
10
+ name: 'PaymentWebhookEvent',
11
+ table: 'payment_webhook_events',
12
+ primaryKey: 'id',
13
+ autoIncrement: true,
14
+
15
+ indexes: [
16
+ {
17
+ name: 'payment_webhook_events_provider_event_unique',
18
+ columns: ['provider', 'event_id'],
19
+ unique: true,
20
+ },
21
+ ],
22
+
23
+ // Written by the system on a provider's behalf, never for a caller.
24
+ ownership: false,
25
+
26
+ traits: {
27
+ useTimestamps: true,
28
+ },
29
+
30
+ attributes: {
31
+ /** The payment driver that verified it: `stripe`, `adyen`, or one you registered. */
32
+ provider: {
33
+ required: true,
34
+ fillable: true,
35
+ validation: {
36
+ rule: schema.string().required().max(64),
37
+ },
38
+ },
39
+ eventId: {
40
+ required: true,
41
+ fillable: true,
42
+ validation: {
43
+ rule: schema.string().required().max(512),
44
+ },
45
+ },
46
+ processedAt: {
47
+ required: true,
48
+ fillable: true,
49
+ validation: {
50
+ rule: schema.timestamp().required(),
51
+ },
52
+ },
53
+ },
54
+
55
+ dashboard: { enabled: false },
56
+ } as const)
@@ -79,17 +79,21 @@ function normalizeSubscription(value: unknown): BillingSubscription | null {
79
79
  if (Object.keys(stored).length === 0)
80
80
  return null
81
81
 
82
- const price = record(records(record(provider.items).data)[0]?.price)
82
+ // The provider subscription in the payment driver's terms: `price` carries
83
+ // its name and amount, the period end is a date. It used to be Stripe's own
84
+ // object, read through `items.data[0].price`.
85
+ const price = record(provider.price)
86
+ const priceAmount = record(price.amount)
83
87
 
84
88
  return {
85
89
  id: text(stored.id, stored.uuid, stored.provider_id, stored.providerId),
86
- plan: text(stored.plan, stored.type, price.nickname, price.lookup_key),
90
+ plan: text(stored.plan, stored.type, price.name),
87
91
  type: text(stored.type, stored.plan),
88
92
  status: text(stored.provider_status, stored.providerStatus, provider.status),
89
- amount: number(stored.unit_price ?? stored.unitPrice ?? price.unit_amount),
90
- currency: text(price.currency, provider.currency),
91
- periodEnd: timestamp(provider.current_period_end ?? stored.ends_at ?? stored.endsAt),
92
- cancelAtPeriodEnd: Boolean(provider.cancel_at_period_end),
93
+ amount: number(stored.unit_price ?? stored.unitPrice ?? priceAmount.amount),
94
+ currency: text(priceAmount.currency),
95
+ periodEnd: timestamp(provider.currentPeriodEnd ?? stored.ends_at ?? stored.endsAt),
96
+ cancelAtPeriodEnd: Boolean(provider.cancelAtPeriodEnd),
93
97
  }
94
98
  }
95
99
 
@@ -101,7 +105,7 @@ function normalizePaymentMethod(value: unknown, defaultId: string): BillingPayme
101
105
  return {
102
106
  id,
103
107
  brand: text(card.brand, method.brand, method.type),
104
- lastFour: text(card.last4, method.last_four, method.lastFour),
108
+ lastFour: text(method.last4, card.last4, method.last_four, method.lastFour),
105
109
  expMonth: number(card.exp_month ?? method.exp_month ?? method.expMonth),
106
110
  expYear: number(card.exp_year ?? method.exp_year ?? method.expYear),
107
111
  isDefault: Boolean(method.is_default ?? method.isDefault) || Boolean(id && id === defaultId),
@@ -1,3 +1,6 @@
1
+ // The stylesheet's text-import declaration travels with this module, so an app
2
+ // typechecking it gets the declaration whatever its tsconfig includes.
3
+ /// <reference path="./xterm-styles.d.ts" />
1
4
  import type { FitAddon } from '@xterm/addon-fit'
2
5
  import type { Terminal } from '@xterm/xterm'
3
6
  import { DashboardApiError } from './dashboard-api'
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ /**
6
+ * A defaults module that imports a stylesheet (or any non-code file) as text
7
+ * needs a `declare module` for that specifier, and an app has to SEE it. The
8
+ * declaration used to sit beside the module unreferenced: this repo's tsconfig
9
+ * happened to include it, an app's does not, so every app's `buddy typecheck`
10
+ * failed on stacks 0.75.75 (`Cannot find module '@xterm/xterm/css/xterm.css'`).
11
+ *
12
+ * The rule this holds: such a module references its declaration with
13
+ * `/// <reference path>`, so it reaches any program that typechecks the module.
14
+ */
15
+
16
+ const defaultsRoot = resolve(import.meta.dir, '..')
17
+ const TEXT_IMPORT = /import\(\s*'([^']+)'\s*,\s*\{\s*with:\s*\{\s*type:\s*'text'\s*\}\s*\}\s*\)|from\s+'([^']+)'\s+with\s+\{\s*type:\s*'text'\s*\}/g
18
+ const REFERENCE = /^\/\/\/\s*<reference\s+path="([^"]+)"\s*\/>/gm
19
+
20
+ function textImports(source: string): string[] {
21
+ return [...source.matchAll(TEXT_IMPORT)].map(match => (match[1] ?? match[2])!).filter(spec => !spec.startsWith('.'))
22
+ }
23
+
24
+ function declaredByReferences(file: string, source: string): string {
25
+ return [...source.matchAll(REFERENCE)].map(match => readFileSync(join(dirname(file), match[1]!), 'utf8')).join('\n')
26
+ }
27
+
28
+ const modules = [...new Bun.Glob('**/*.ts').scanSync({ cwd: defaultsRoot, absolute: true })]
29
+ .filter(file => !file.includes('/node_modules/') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))
30
+ .map(file => ({ file, source: readFileSync(file, 'utf8') }))
31
+ .filter(({ source }) => textImports(source).length > 0)
32
+
33
+ describe('text imports in the defaults', () => {
34
+ it('finds the ones there are', () => {
35
+ // The scan is only worth something while it sees the case that broke.
36
+ expect(modules.map(({ file }) => file.slice(defaultsRoot.length + 1))).toContain('functions/remote-terminal-element.ts')
37
+ })
38
+
39
+ it('reference a declaration for each specifier, so an app typechecking the module sees it', () => {
40
+ const missing = modules.flatMap(({ file, source }) => {
41
+ const declared = declaredByReferences(file, source)
42
+ return textImports(source)
43
+ .filter(spec => !declared.includes(`declare module '${spec}'`))
44
+ .map(spec => `${file.slice(defaultsRoot.length + 1)}: ${spec}`)
45
+ })
46
+ expect(missing).toEqual([])
47
+ })
48
+ })
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.76",
5
+ "version": "0.75.77",
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.76",
5
+ "version": "0.75.77",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.76",
58
+ "@stacksjs/mobile": "^0.75.77",
59
59
  "@stacksjs/sanitizer": "^0.2.378",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
package/stores/payment.ts CHANGED
@@ -28,6 +28,21 @@ function requestHeaders(write = false): Record<string, string> {
28
28
  return write ? withCsrfHeader(headers) : headers
29
29
  }
30
30
 
31
+ /**
32
+ * The Stripe client secret in a payment endpoint's answer. The endpoints
33
+ * answer in the payment driver's terms - `clientConfirmation` - and this
34
+ * store confirms with Stripe.js, so a session for another provider (Adyen's
35
+ * Drop-in) is refused by name rather than handed to Stripe.
36
+ */
37
+ function stripeClientSecret(result: { clientConfirmation?: { provider: string, clientSecret?: string } }): string {
38
+ const confirmation = result.clientConfirmation
39
+ if (!confirmation)
40
+ throw new Error('The payment needs nothing confirmed in the browser.')
41
+ if (confirmation.provider !== 'stripe' || !confirmation.clientSecret)
42
+ throw new Error(`This store confirms Stripe payments; the configured provider is ${confirmation.provider}.`)
43
+ return confirmation.clientSecret
44
+ }
45
+
31
46
  const paymentStore = defineStore('payment', () => {
32
47
  // TODO: update the any types
33
48
  const loadingStates = ref<Record<string, boolean>>({})
@@ -69,10 +84,7 @@ const paymentStore = defineStore('payment', () => {
69
84
  throw new Error(`Failed to fetch setup intent: ${response.status}`)
70
85
  }
71
86
 
72
- const client: any = await response.json()
73
- const clientSecret = client.client_secret
74
-
75
- return clientSecret
87
+ return stripeClientSecret(await response.json())
76
88
  }
77
89
 
78
90
  async function fetchPaymentIntent(id: number, productId: number): Promise<string> {
@@ -90,13 +102,10 @@ const paymentStore = defineStore('payment', () => {
90
102
  throw new Error(`Failed to create payment intent: ${response.status}`)
91
103
  }
92
104
 
93
- const client: any = await response.json()
94
- const clientSecret = client.client_secret
95
-
96
- return clientSecret
105
+ return stripeClientSecret(await response.json())
97
106
  }
98
107
 
99
- async function storeTransaction(id: number, productId: number): Promise<string> {
108
+ async function storeTransaction(id: number, productId: number): Promise<TransactionHistory> {
100
109
  const body = { productId }
101
110
 
102
111
  const url = `${apiUrl}/payments/store-transaction/${id}`
@@ -111,10 +120,9 @@ const paymentStore = defineStore('payment', () => {
111
120
  throw new Error(`Failed to store transaction: ${response.status}`)
112
121
  }
113
122
 
114
- const client: any = await response.json()
115
- const clientSecret = client.client_secret
116
-
117
- return clientSecret
123
+ // The stored transaction. This read `client_secret` off it, which a
124
+ // transaction never has, so callers always got undefined.
125
+ return await response.json()
118
126
  }
119
127
 
120
128
  async function subscribeToPlan(body: { type: string, plan: string, description: string }): Promise<string> {
@@ -264,13 +272,15 @@ const paymentStore = defineStore('payment', () => {
264
272
  if (response.status !== 204) {
265
273
  const res = await response.json()
266
274
 
267
- transactionHistory.value = res.data
275
+ // The endpoint answers the rows themselves; `.data` was always undefined.
276
+ transactionHistory.value = Array.isArray(res) ? res : (res?.data ?? [])
268
277
  }
269
278
 
270
279
  removeLoadingState('fetchTransactionHistory')
271
280
  }
272
281
 
273
- async function deletePaymentMethod(paymentMethod: number): Promise<void> {
282
+ /** `paymentMethod` is the provider's id, as `paymentMethods` lists it. */
283
+ async function deletePaymentMethod(paymentMethod: string): Promise<void> {
274
284
  setLoadingState('deletePaymentMethod')
275
285
  const url = `${apiUrl}/payments/delete-payment-method/${authenticatedUserId()}`
276
286