@stacksjs/defaults 0.75.76 → 0.75.78
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-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/ai/skills/stacks-orm/SKILL.md +7 -12
- package/ai/skills/stacks-payments/SKILL.md +23 -7
- package/ai/skills/stacks-types/SKILL.md +1 -1
- package/app/Actions/Dashboard/Settings/BillingShowAction.ts +6 -2
- package/app/Actions/Payment/CancelSubscriptionAction.ts +5 -6
- package/app/Actions/Payment/CreateCheckoutAction.ts +34 -13
- package/app/Actions/Payment/CreateInvoiceSubscription.ts +27 -9
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +23 -10
- package/app/Actions/Payment/CreateSetupIntentAction.ts +9 -5
- package/app/Actions/Payment/CreateSubscriptionAction.ts +13 -7
- package/app/Actions/Payment/DeleteDefaultPaymentAction.ts +12 -2
- package/app/Actions/Payment/FetchActiveSubscriptionAction.ts +8 -3
- package/app/Actions/Payment/FetchPaymentCustomerAction.ts +9 -5
- package/app/Actions/Payment/FetchPaymentMethodsAction.ts +7 -3
- package/app/Actions/Payment/SetDefaultPaymentAction.ts +14 -7
- package/app/Actions/Payment/SetUserDefaultPaymentAction.ts +14 -6
- package/app/Actions/Payment/StorePaymentMethodAction.ts +7 -3
- package/app/Actions/Payment/UpdateDefaultPaymentMethodAction.ts +13 -2
- package/app/Actions/Payment/UpdateSubscriptionAction.ts +11 -6
- package/app/Actions/Payment/payment-response.ts +50 -0
- package/app/Models/commerce/Payment.ts +10 -0
- package/app/Models/commerce/PaymentWebhookEvent.ts +56 -0
- package/functions/billing/dashboard.test.ts +69 -0
- package/functions/billing/dashboard.ts +11 -7
- package/functions/remote-terminal-element.ts +3 -0
- package/functions/text-import-declarations.test.ts +48 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- 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
|
|
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. (
|
|
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
|
|
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/` (
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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/` (
|
|
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`) --
|
|
243
|
-
All methods lazy-import `@stacksjs/payments`.
|
|
244
|
-
- `
|
|
245
|
-
-
|
|
246
|
-
- `defaultPaymentMethod(model)`, `
|
|
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()`
|
|
@@ -16,9 +16,12 @@ Full Stripe integration via the Payment facade. Uses Stripe API version `2026-01
|
|
|
16
16
|
|
|
17
17
|
- Money is `{ amount, currency }` in minor units; `assertMoney` refuses floats before a request leaves.
|
|
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
|
+
- `checkout` copies `reference` and `metadata` onto the payment / subscription / setup intent it creates (Stripe: `payment_intent_data`, `subscription_data`, `setup_intent_data`), so their webhooks can be attributed. `allowPromotionCodes` and `automaticTax` are Stripe only; Adyen refuses them.
|
|
19
20
|
- Unsupported operations throw `PaymentUnsupportedError` (driver + operation). Adyen has no catalog, subscriptions, invoices, coupons or portal.
|
|
20
21
|
- 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.
|
|
22
|
+
- `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
23
|
- The Stripe driver delegates to the existing billable modules, so idempotency keys and `stripe_id` handling are unchanged.
|
|
24
|
+
- 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
25
|
- Everything below this section is Stripe's own API and returns Stripe objects.
|
|
23
26
|
|
|
24
27
|
## Key Paths
|
|
@@ -487,6 +490,7 @@ for the plans it is meant for.
|
|
|
487
490
|
- `payment_methods` -- columns: `id`, `type`, `last_four`, `brand`, `exp_year`, `exp_month`, `user_id`, `provider_id`, `is_default`
|
|
488
491
|
- `payment_products` -- columns: `id`, `name`, `unit_price`
|
|
489
492
|
- `payment_transactions` -- columns: `id`, `name`, `description`, `amount`, `brand`, `type`, `provider_id`, `user_id`
|
|
493
|
+
- `payment_webhook_events` -- columns: `provider`, `event_id` (unique together), `processed_at`; commerce's webhook dedup
|
|
490
494
|
|
|
491
495
|
## User Model Requirements
|
|
492
496
|
The `UserModel` must have:
|
|
@@ -521,12 +525,24 @@ const customer = await user.retrieveStripeUser()
|
|
|
521
525
|
```
|
|
522
526
|
|
|
523
527
|
The instance methods are exactly the keys of `createBillableMethods` in
|
|
524
|
-
`core/orm/src/traits/billable.ts`.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`
|
|
528
|
-
`
|
|
529
|
-
|
|
528
|
+
`core/orm/src/traits/billable.ts`. Anything else is not a function at runtime.
|
|
529
|
+
|
|
530
|
+
- Provider-neutral, through the configured driver: `paymentCustomer()`,
|
|
531
|
+
`charge(money, paymentMethodId, options)`, `createPayment(money, options)`,
|
|
532
|
+
`checkout({ mode, lines, successUrl, cancelUrl })`, `paymentMethods()`,
|
|
533
|
+
`removePaymentMethod(providerId)`, `newSubscription(type, price)`,
|
|
534
|
+
`cancelSubscription(providerId, { atPeriodEnd })` (refuses a subscription the
|
|
535
|
+
record does not own), `activeSubscription()`. Money is `{ amount, currency }`
|
|
536
|
+
in minor units; results are the driver's shapes, with `raw` holding the
|
|
537
|
+
provider's object (strip it before sending to a browser).
|
|
538
|
+
- Stripe only, throwing `PaymentUnsupportedError` under another driver:
|
|
539
|
+
`createStripeUser`, `updateStripeUser`, `deleteStripeUser`,
|
|
540
|
+
`createOrGetStripeUser`, `retrieveStripeUser`, `syncStripeCustomerDetails`,
|
|
541
|
+
`setDefaultPaymentMethod(id)` (a number is the local row, a string Stripe's
|
|
542
|
+
`pm_...` id), `addPaymentMethod`, `updateSubscription`, `createSetupIntent`,
|
|
543
|
+
`subscriptionHistory`, and the Connect methods.
|
|
544
|
+
- Local only: `defaultPaymentMethod()`, `storeTransaction(productId, options)`,
|
|
545
|
+
`transactionHistory()`.
|
|
530
546
|
|
|
531
547
|
## Gotchas
|
|
532
548
|
- Stripe API keys MUST be in `.env` as `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` -- never hardcode them in config files
|
|
@@ -534,7 +550,7 @@ runtime.
|
|
|
534
550
|
- All amounts are in cents -- use `toCents()` and `toDollars()` for conversion
|
|
535
551
|
- `charge()` creates AND confirms the PaymentIntent in one step
|
|
536
552
|
- `subscribe()` resolves the price via `lookup_key`, not a direct Stripe price ID
|
|
537
|
-
- `removePaymentMethod()` takes a database record ID (number)
|
|
553
|
+
- The facade's `Payment.removePaymentMethod()` takes a database record ID (number); the billable instance method `removePaymentMethod()` takes the provider's id (string)
|
|
538
554
|
- `setDefaultPaymentMethod` has two variants: one takes a Stripe PM ID string (`setUserDefaultPayment`), the other takes a database ID number
|
|
539
555
|
- `getOrCreateCustomer()` handles deleted Stripe customers by recreating them
|
|
540
556
|
- 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 (
|
|
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:
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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')
|
|
12
|
-
const plan = request.get('plan')
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 }`;
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
const
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
12
|
-
const plan = request.get('plan')
|
|
13
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { fetchBillingOverview } from './dashboard'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The billing page reads what BillingShowAction sends: the stored subscription
|
|
6
|
+
* row beside the payment driver's `SubscriptionSummary`, and the driver's
|
|
7
|
+
* `StoredPaymentMethod`s - JSON, so dates arrive as ISO strings. It used to
|
|
8
|
+
* read Stripe's own objects (`items.data[0].price`, `current_period_end`),
|
|
9
|
+
* which the driver no longer passes to a browser.
|
|
10
|
+
*/
|
|
11
|
+
const realFetch = globalThis.fetch
|
|
12
|
+
|
|
13
|
+
function serve(payload: unknown): void {
|
|
14
|
+
globalThis.fetch = (async () => Response.json(payload)) as unknown as typeof fetch
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
globalThis.fetch = realFetch
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('fetchBillingOverview', () => {
|
|
22
|
+
it('reads the plan, amount and period from the driver\'s subscription summary', async () => {
|
|
23
|
+
serve({
|
|
24
|
+
subscription: {
|
|
25
|
+
subscription: { id: 4, type: 'default', provider_id: 'sub_1', provider_status: 'active' },
|
|
26
|
+
providerSubscription: {
|
|
27
|
+
id: 'sub_1',
|
|
28
|
+
status: 'active',
|
|
29
|
+
price: { id: 'price_pro', name: 'Pro monthly', amount: { amount: 2900, currency: 'eur' }, interval: 'month' },
|
|
30
|
+
currentPeriodEnd: '2026-11-05T00:00:00.000Z',
|
|
31
|
+
cancelAtPeriodEnd: true,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
paymentMethods: [],
|
|
35
|
+
transactions: [],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { subscription } = await fetchBillingOverview()
|
|
39
|
+
expect(subscription).toEqual({
|
|
40
|
+
// The provider's id: what cancelling the subscription takes.
|
|
41
|
+
id: 'sub_1',
|
|
42
|
+
plan: 'default',
|
|
43
|
+
type: 'default',
|
|
44
|
+
status: 'active',
|
|
45
|
+
amount: 2900,
|
|
46
|
+
currency: 'eur',
|
|
47
|
+
periodEnd: '2026-11-05T00:00:00.000Z',
|
|
48
|
+
cancelAtPeriodEnd: true,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reads the driver\'s stored payment methods, default included', async () => {
|
|
53
|
+
serve({
|
|
54
|
+
subscription: null,
|
|
55
|
+
paymentMethods: [
|
|
56
|
+
{ id: 'pm_1', type: 'card', brand: 'visa', last4: '4242', expMonth: 4, expYear: 2030, isDefault: false },
|
|
57
|
+
{ id: 'pm_2', type: 'card', brand: 'amex', last4: '0005', expMonth: 1, expYear: 2031, isDefault: true },
|
|
58
|
+
],
|
|
59
|
+
transactions: [],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const { paymentMethods, subscription } = await fetchBillingOverview()
|
|
63
|
+
expect(subscription).toBeNull()
|
|
64
|
+
expect(paymentMethods).toEqual([
|
|
65
|
+
{ id: 'pm_1', brand: 'visa', lastFour: '4242', expMonth: 4, expYear: 2030, isDefault: false },
|
|
66
|
+
{ id: 'pm_2', brand: 'amex', lastFour: '0005', expMonth: 1, expYear: 2031, isDefault: true },
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -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
|
-
|
|
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.
|
|
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 ??
|
|
90
|
-
currency: text(
|
|
91
|
-
periodEnd: timestamp(provider.
|
|
92
|
-
cancelAtPeriodEnd: Boolean(provider.
|
|
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
|
+
})
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.78",
|
|
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.
|
|
5
|
+
"version": "0.75.78",
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.78",
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|