@jskit-ai/payments-web 0.1.1
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/README.md +149 -0
- package/fixtures/payment-account/index.html +5 -0
- package/fixtures/payment-account/main.js +25 -0
- package/fixtures/payment-account/vite.config.mjs +9 -0
- package/package.json +31 -0
- package/src/client/components/PaymentAccount.vue +117 -0
- package/test/paymentAccount.browser.test.js +68 -0
- package/test/paymentAccount.test.js +116 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Application billing UI
|
|
2
|
+
|
|
3
|
+
`@jskit-ai/payments-web` supplies a Vue/Vuetify account component for generated or
|
|
4
|
+
hand-written JavaScript applications. It has no Vibe64, Online, Genesis, provider
|
|
5
|
+
SDK, credential, file-discovery or HTTP dependency. The application mounts it in
|
|
6
|
+
its existing billing page or semantic placement. It does not register routes or
|
|
7
|
+
change shell navigation automatically. This worktree package is not yet published.
|
|
8
|
+
|
|
9
|
+
## Component contract
|
|
10
|
+
|
|
11
|
+
Import `PaymentAccount` from
|
|
12
|
+
`@jskit-ai/payments-web/client/components/PaymentAccount`.
|
|
13
|
+
|
|
14
|
+
| Prop | Meaning |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `account` | Authorized account projection: integer `balance`, `features` string array, `subscriptions` array, boolean `hasCustomer`; null until loaded |
|
|
17
|
+
| `plans` | Display records: `id`, `name`, `priceLabel`, `features` array, integer `renewalCredits`, boolean `available` |
|
|
18
|
+
| `loading` | Initial or refresh request in progress |
|
|
19
|
+
| `loadError` | Safe user-facing read failure; replaces stale action controls with Retry |
|
|
20
|
+
| `pending` | Checkout or portal request in progress; disables mutation controls |
|
|
21
|
+
| `canManage` | App authorization display hint, false by default; never replaces server authorization |
|
|
22
|
+
| `canReadHistory` | Separate billing-history read permission hint, false by default |
|
|
23
|
+
| `history` | One authorized `{collection, items, nextCursor}` provider page, or null |
|
|
24
|
+
| `historyLoading` | History request pending; displays skeletons and disables page/collection requests |
|
|
25
|
+
| `historyError` | Safe history read failure; hides stale rows and offers Retry |
|
|
26
|
+
| `locale` | Optional locale for integer credit and UTC date formatting |
|
|
27
|
+
|
|
28
|
+
Subscription records contain `id`, `planId`, `status`, and `periodEnd` in UTC
|
|
29
|
+
milliseconds. `payments-core`'s authorized `checkout.account({actor, subjectId})`
|
|
30
|
+
returns the account projection. The app derives actor and subject from its own
|
|
31
|
+
authenticated identity and membership. Its `authorize` callback receives action
|
|
32
|
+
`account`; allow read-only members independently of `checkout` and `portal` if
|
|
33
|
+
that is the application's policy.
|
|
34
|
+
|
|
35
|
+
Build display plans on the server from validated configuration and the selected
|
|
36
|
+
merchant/environment's published catalogue. `available` is true only for a
|
|
37
|
+
published price the current runtime adapter can sell. Format `priceLabel` using
|
|
38
|
+
the currency's supported provider minor-unit rules and the user's locale; do not
|
|
39
|
+
assume every currency has two decimal places. Include billing interval and any
|
|
40
|
+
applicable tax clarification. Never serialize the entire integration config or
|
|
41
|
+
Env into these props. Display price and availability are not checkout authority:
|
|
42
|
+
the server chooses the published provider price from the submitted logical ID.
|
|
43
|
+
|
|
44
|
+
The component emits:
|
|
45
|
+
|
|
46
|
+
- `checkout(planId)`: request checkout for a selected logical plan.
|
|
47
|
+
- `portal()`: request a short-lived customer portal session.
|
|
48
|
+
- `refresh()`: reload current server state, including after returning from checkout.
|
|
49
|
+
- `history({collection, after})`: load the selected provider history page.
|
|
50
|
+
- `retry-history()`: retry the app's last failed history query.
|
|
51
|
+
|
|
52
|
+
An open subscription, including overdue, paused or incomplete, disables another
|
|
53
|
+
checkout and directs the customer to billing management. Cancellation or an
|
|
54
|
+
expired incomplete subscription permits a new checkout. The provider portal's
|
|
55
|
+
plan-change/cancellation options must be configured by the merchant. A disabled
|
|
56
|
+
button is a UI guard only. The server service also rejects a new checkout when
|
|
57
|
+
its reconciled account contains an open subscription, while allowing recovery of
|
|
58
|
+
an already completed request ID. Before webhook reconciliation, separate checkout
|
|
59
|
+
sessions can still be open at the provider; retain one request ID for an ongoing
|
|
60
|
+
purchase intent. Application authorization remains mandatory on every request.
|
|
61
|
+
|
|
62
|
+
## Application composition
|
|
63
|
+
|
|
64
|
+
Keep the page small. Use the application's existing `useView()` for the account
|
|
65
|
+
read and `useCommand()` for checkout and portal, from `@jskit-ai/http-web`.
|
|
66
|
+
These supply scoped paths, credentials/CSRF, query state and shared mutation
|
|
67
|
+
feedback. Bind their states and actions to the component:
|
|
68
|
+
|
|
69
|
+
```vue
|
|
70
|
+
<PaymentAccount
|
|
71
|
+
:account="billing.record?.account ?? null"
|
|
72
|
+
:plans="billing.record?.plans ?? []"
|
|
73
|
+
:can-manage="billing.record?.canManage === true"
|
|
74
|
+
:loading="billing.isLoading || billing.isFetching"
|
|
75
|
+
:load-error="billing.loadError"
|
|
76
|
+
:pending="checkout.isRunning || portal.isRunning"
|
|
77
|
+
@refresh="billing.refresh()"
|
|
78
|
+
@checkout="startCheckout"
|
|
79
|
+
@portal="portal.run()"
|
|
80
|
+
/>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Here `billing`, `checkout` and `portal` are the app's normal configured hooks,
|
|
84
|
+
not exports of this package. Mount the routes with the framework already used by
|
|
85
|
+
the app. Connect them to the explicitly composed server services:
|
|
86
|
+
|
|
87
|
+
| App route purpose | Server call |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Read billing account | `checkoutService.account({actor, subjectId})`, plus safe display plans and the app's management permission |
|
|
90
|
+
| Begin checkout | `checkoutService.checkout({actor, subjectId, email, planId, requestId})` |
|
|
91
|
+
| Open billing portal | `checkoutService.portal({actor, subjectId})` |
|
|
92
|
+
| Provider webhook | `checkoutService.webhook({rawBody, signature})` |
|
|
93
|
+
| Read provider billing history | `checkoutService.history({actor, subjectId, collection, after})` |
|
|
94
|
+
|
|
95
|
+
History uses the app's existing `useList()` or `useEndpointResource()` read hook,
|
|
96
|
+
with query identity including billable subject, collection and cursor. Bind its
|
|
97
|
+
loading/error states and clear the prior subject's page when identity changes.
|
|
98
|
+
On `history`, select that query; on `retry-history`, refetch the failed query.
|
|
99
|
+
Replace the page instead of automatically walking every provider page. A new
|
|
100
|
+
collection selection starts at `after: null`; the Next control uses `nextCursor`.
|
|
101
|
+
Reject stale results after subject changes, and disable reads when the app's
|
|
102
|
+
history policy denies access. No request orchestration is duplicated here.
|
|
103
|
+
|
|
104
|
+
History item fields come from the core contract: `id`, `kind`, `status`,
|
|
105
|
+
`createdAt`, and financial records' `currency`, `totalMinor`, `paidMinor`. The app
|
|
106
|
+
adds `totalLabel` and, when the paid amount is known, `paidLabel`, using its money
|
|
107
|
+
formatter and currency-specific minor units. Keep large integer strings exact.
|
|
108
|
+
Null amounts stay unknown. Stripe rows are invoices; Paddle rows are transactions.
|
|
109
|
+
Neither a row nor its total proves settlement, and this view does not grant access
|
|
110
|
+
or credits. This is customer-scoped history, not merchant-wide reporting or
|
|
111
|
+
refund/cancellation administration.
|
|
112
|
+
|
|
113
|
+
The app's `startCheckout(planId)` creates and retains a request ID for that user
|
|
114
|
+
intent, including retry after a lost HTTP response; it calls the checkout command
|
|
115
|
+
with that ID and plan ID. The backend gets email from authenticated billing
|
|
116
|
+
contact data, not arbitrary browser identity claims. Serialize concurrent billing
|
|
117
|
+
mutations for the same account through the existing service. An uncertain
|
|
118
|
+
provider outcome requires the documented server reconciliation operation, not
|
|
119
|
+
an automatic retry loop.
|
|
120
|
+
|
|
121
|
+
On successful checkout/portal command, navigate to the URL returned by the
|
|
122
|
+
app's server. Do not accept an arbitrary return/provider URL from query parameters
|
|
123
|
+
or browser inputs. Configure the provider and destination in server composition.
|
|
124
|
+
Mutation errors use `useCommand()`'s normal feedback path. The component does not
|
|
125
|
+
insert a second error banner or duplicate that request state. Re-read on the
|
|
126
|
+
return route; a success query parameter must never grant paid access.
|
|
127
|
+
|
|
128
|
+
Laravel owns its native server and UI implementation. An Inertia/Vue application
|
|
129
|
+
can choose this presentational component, but no JSKIT runtime is required for
|
|
130
|
+
Laravel to implement the documented JSON account/plan display shape and actions.
|
|
131
|
+
|
|
132
|
+
## Evidence
|
|
133
|
+
|
|
134
|
+
The focused package test compiles and mounts the Vue component with lightweight
|
|
135
|
+
host controls and checks loaded, pending, overdue, unauthorized, error/retry and
|
|
136
|
+
loading behavior. A separate browser fixture uses real Vuetify and controlled
|
|
137
|
+
component inputs, without a generated application or provider connection:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
JSKIT_PAYMENTS_WEB_BROWSER_INTEGRATION=1 node --test --test-concurrency=1 packages/payments-web/test/paymentAccount.browser.test.js
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Run that command from the JSKIT repository root with its prepared browser tools.
|
|
144
|
+
It checks 390, 768, 1024 and 1440 pixel widths plus a short desktop viewport,
|
|
145
|
+
unclipped invoice references, 48-pixel controls, keyboard checkout/portal events,
|
|
146
|
+
history pagination/retry, denied and pending actions, overdue subscriptions,
|
|
147
|
+
account retry and initial loading. This verifies the component's presentation
|
|
148
|
+
and emitted events; the consuming app still owns authorized routes, provider
|
|
149
|
+
requests and payment completion. It is not live-provider acceptance.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Billing component fixture</title></head>
|
|
4
|
+
<body><div id="app"></div><script type="module" src="/main.js"></script></body>
|
|
5
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createApp, h, reactive } from "vue";
|
|
2
|
+
import { createVuetify } from "vuetify";
|
|
3
|
+
import * as components from "vuetify/components";
|
|
4
|
+
import * as directives from "vuetify/directives";
|
|
5
|
+
import "vuetify/styles";
|
|
6
|
+
import PaymentAccount from "../../src/client/components/PaymentAccount.vue";
|
|
7
|
+
|
|
8
|
+
// Controlled component inputs only: no server, identity exchange or provider calls.
|
|
9
|
+
const state = reactive({
|
|
10
|
+
account: { balance: 1200, features: ["Scheduling", "Customer reminders"], subscriptions: [], hasCustomer: true },
|
|
11
|
+
plans: [{ id: "studio", name: "Studio plan", priceLabel: "$20.00 / month", features: ["Scheduling", "Customer reminders"], renewalCredits: 1200, available: true }],
|
|
12
|
+
canManage: true, canReadHistory: true, pending: false, loading: false, loadError: "",
|
|
13
|
+
historyLoading: false, historyError: "",
|
|
14
|
+
history: { collection: "transactions", items: [{ id: "invoice_" + "long_reference_".repeat(12), kind: "invoice", status: "open", createdAt: "2026-09-12T12:00:00Z", totalLabel: "$20.00", paidLabel: "$0.00" }], nextCursor: "next-1" },
|
|
15
|
+
locale: "en-US"
|
|
16
|
+
});
|
|
17
|
+
window.paymentFixture = { state, events: [] };
|
|
18
|
+
const record = (name) => (value) => window.paymentFixture.events.push({ name, value });
|
|
19
|
+
createApp({
|
|
20
|
+
setup: () => () => h(components.VApp, {}, { default: () => h(components.VMain, {}, {
|
|
21
|
+
default: () => h("div", { style: "padding:16px;max-width:1200px;margin:auto" }, [
|
|
22
|
+
h(PaymentAccount, { ...state, onCheckout: record("checkout"), onPortal: record("portal"), onRefresh: record("refresh"), onHistory: record("history"), "onRetry-history": record("retry-history") })
|
|
23
|
+
])
|
|
24
|
+
}) })
|
|
25
|
+
}).use(createVuetify({ components, directives })).mount("#app");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import vue from "@vitejs/plugin-vue";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL(".", import.meta.url)),
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
server: { host: "127.0.0.1" }
|
|
9
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/payments-web",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Application-owned billing views for JSKIT Vue applications.",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./client/components/PaymentAccount": "./src/client/components/PaymentAccount.vue"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"vue": "^3.5.13",
|
|
11
|
+
"vuetify": "^4.0.0"
|
|
12
|
+
},
|
|
13
|
+
"jskit": {
|
|
14
|
+
"kind": "runtime",
|
|
15
|
+
"capabilities": {
|
|
16
|
+
"provides": [],
|
|
17
|
+
"requires": []
|
|
18
|
+
},
|
|
19
|
+
"runtime": {
|
|
20
|
+
"server": {
|
|
21
|
+
"providers": []
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"providers": []
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test --test-concurrency=1"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
plans: { type: Array, default: () => [] },
|
|
6
|
+
account: { type: Object, default: null },
|
|
7
|
+
loading: { type: Boolean, default: false },
|
|
8
|
+
loadError: { type: String, default: "" },
|
|
9
|
+
pending: { type: Boolean, default: false },
|
|
10
|
+
canManage: { type: Boolean, default: false },
|
|
11
|
+
canReadHistory: { type: Boolean, default: false },
|
|
12
|
+
history: { type: Object, default: null },
|
|
13
|
+
historyLoading: { type: Boolean, default: false },
|
|
14
|
+
historyError: { type: String, default: "" },
|
|
15
|
+
locale: { type: String, default: undefined }
|
|
16
|
+
});
|
|
17
|
+
const emit = defineEmits(["refresh", "checkout", "portal", "history", "retry-history"]);
|
|
18
|
+
const subscriptions = computed(() => props.account?.subscriptions ?? []);
|
|
19
|
+
const hasSubscription = computed(() => subscriptions.value.some((item) =>
|
|
20
|
+
!["canceled", "incomplete_expired"].includes(item.status)));
|
|
21
|
+
const disabled = computed(() => props.pending || props.loading || Boolean(props.loadError) || !props.account || !props.canManage);
|
|
22
|
+
const statuses = {
|
|
23
|
+
active: "Active", trialing: "Trial", past_due: "Payment overdue", paused: "Paused",
|
|
24
|
+
canceled: "Canceled", unpaid: "Unpaid", incomplete: "Payment incomplete",
|
|
25
|
+
incomplete_expired: "Payment expired"
|
|
26
|
+
};
|
|
27
|
+
const number = (value) => new Intl.NumberFormat(props.locale).format(value);
|
|
28
|
+
const date = (value) => new Intl.DateTimeFormat(props.locale, { dateStyle: "medium", timeZone: "UTC" }).format(value);
|
|
29
|
+
const planName = (id) => props.plans.find((plan) => plan.id === id)?.name ?? id;
|
|
30
|
+
function checkout(plan) {
|
|
31
|
+
if (!disabled.value && !hasSubscription.value && plan.available === true) emit("checkout", plan.id);
|
|
32
|
+
}
|
|
33
|
+
function loadHistory(collection, after = null) {
|
|
34
|
+
if (props.canReadHistory && !props.historyLoading && !props.loading) emit("history", { collection, after });
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<section class="payment-account" aria-label="Subscription and billing" :aria-busy="loading || pending">
|
|
40
|
+
<v-skeleton-loader v-if="loading && !account" type="article, list-item-two-line, actions" />
|
|
41
|
+
<div v-else-if="loadError" role="alert">
|
|
42
|
+
<p>{{ loadError }}</p>
|
|
43
|
+
<v-btn variant="tonal" :disabled="loading" @click="emit('refresh')">Retry billing details</v-btn>
|
|
44
|
+
</div>
|
|
45
|
+
<template v-else-if="account">
|
|
46
|
+
<dl class="payment-account__summary">
|
|
47
|
+
<div><dt>Available credits</dt><dd>{{ number(account.balance) }}</dd></div>
|
|
48
|
+
<div><dt>Included features</dt><dd>{{ account.features.length ? account.features.join(', ') : 'No paid features active' }}</dd></div>
|
|
49
|
+
</dl>
|
|
50
|
+
<ul v-if="subscriptions.length" class="payment-account__subscriptions" aria-label="Subscriptions">
|
|
51
|
+
<li v-for="subscription in subscriptions" :key="subscription.id">
|
|
52
|
+
<strong>{{ planName(subscription.planId) }}</strong>
|
|
53
|
+
<span>{{ statuses[subscription.status] ?? subscription.status }}</span>
|
|
54
|
+
<span v-if="subscription.periodEnd > 0">Period end: {{ date(subscription.periodEnd) }} (UTC)</span>
|
|
55
|
+
</li>
|
|
56
|
+
</ul>
|
|
57
|
+
<p v-else>No subscription yet.</p>
|
|
58
|
+
<div class="payment-account__actions">
|
|
59
|
+
<v-btn v-if="account.hasCustomer" variant="tonal" :disabled="disabled" @click="emit('portal')">Manage billing</v-btn>
|
|
60
|
+
<v-btn variant="text" :disabled="loading || pending" @click="emit('refresh')">Refresh billing details</v-btn>
|
|
61
|
+
</div>
|
|
62
|
+
<p v-if="!canManage">A billing administrator can manage this account.</p>
|
|
63
|
+
<p v-else-if="hasSubscription">Use Manage billing to update your subscription or payment details.</p>
|
|
64
|
+
<p>Access and credits update after payment confirmation. Returning from checkout alone does not activate a plan.</p>
|
|
65
|
+
<ul v-if="plans.length" class="payment-account__plans" aria-label="Available plans">
|
|
66
|
+
<li v-for="plan in plans" :key="plan.id" class="payment-account__plan">
|
|
67
|
+
<strong>{{ plan.name }}</strong>
|
|
68
|
+
<p>{{ plan.priceLabel }}</p>
|
|
69
|
+
<ul v-if="plan.features.length"><li v-for="feature in plan.features" :key="feature">{{ feature }}</li></ul>
|
|
70
|
+
<p>{{ number(plan.renewalCredits) }} credits per paid renewal; unused renewal credits expire at period end.</p>
|
|
71
|
+
<v-btn variant="flat" color="primary" :disabled="disabled || hasSubscription || plan.available !== true" :aria-label="`Choose ${plan.name}`" @click="checkout(plan)">Choose plan</v-btn>
|
|
72
|
+
<p v-if="plan.available !== true">Not available for checkout yet.</p>
|
|
73
|
+
</li>
|
|
74
|
+
</ul>
|
|
75
|
+
<p v-else>No plans are available yet.</p>
|
|
76
|
+
<section v-if="canReadHistory" aria-label="Billing history" :aria-busy="historyLoading">
|
|
77
|
+
<div class="payment-account__actions">
|
|
78
|
+
<v-btn variant="tonal" :disabled="historyLoading || loading" @click="loadHistory('transactions')">View invoices and transactions</v-btn>
|
|
79
|
+
<v-btn variant="tonal" :disabled="historyLoading || loading" @click="loadHistory('subscriptions')">View subscription history</v-btn>
|
|
80
|
+
</div>
|
|
81
|
+
<v-skeleton-loader v-if="historyLoading" type="list-item-three-line, list-item-three-line" />
|
|
82
|
+
<div v-else-if="historyError" role="alert">
|
|
83
|
+
<p>{{ historyError }}</p>
|
|
84
|
+
<v-btn variant="text" @click="emit('retry-history')">Retry billing history</v-btn>
|
|
85
|
+
</div>
|
|
86
|
+
<template v-else-if="history">
|
|
87
|
+
<p v-if="history.collection === 'transactions'">Invoices and transactions include unpaid records. Their totals do not confirm payment.</p>
|
|
88
|
+
<v-list v-if="history.items.length" lines="three" aria-label="Provider billing records">
|
|
89
|
+
<v-list-item v-for="item in history.items" :key="item.id">
|
|
90
|
+
<v-list-item-title>{{ item.kind === 'invoice' ? 'Invoice' : item.kind === 'transaction' ? 'Transaction' : 'Subscription' }} {{ item.id }}</v-list-item-title>
|
|
91
|
+
<v-list-item-subtitle>{{ statuses[item.status] ?? item.status }} · {{ date(Date.parse(item.createdAt)) }} (UTC)</v-list-item-subtitle>
|
|
92
|
+
<p v-if="item.kind !== 'subscription'">Total: {{ item.totalLabel ?? 'Not available yet' }}<span v-if="item.paidLabel"> · Paid: {{ item.paidLabel }}</span></p>
|
|
93
|
+
</v-list-item>
|
|
94
|
+
</v-list>
|
|
95
|
+
<p v-else>No billing records found.</p>
|
|
96
|
+
<v-btn v-if="history.nextCursor" variant="text" @click="loadHistory(history.collection, history.nextCursor)">Next billing page</v-btn>
|
|
97
|
+
</template>
|
|
98
|
+
</section>
|
|
99
|
+
</template>
|
|
100
|
+
<p v-else>Billing details are not available yet.</p>
|
|
101
|
+
</section>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<style scoped>
|
|
105
|
+
.payment-account { min-width: 0; overflow-wrap: anywhere; }
|
|
106
|
+
.payment-account__summary { display: flex; flex-wrap: wrap; gap: 1rem 2rem; margin: 0 0 1rem; }
|
|
107
|
+
.payment-account__summary dt { font-weight: 600; }
|
|
108
|
+
.payment-account__summary dd { margin: .25rem 0 0; }
|
|
109
|
+
.payment-account__subscriptions { list-style: none; padding: 0; }
|
|
110
|
+
.payment-account__subscriptions li { display: flex; flex-wrap: wrap; gap: .5rem 1rem; padding-block: .5rem; }
|
|
111
|
+
.payment-account__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
112
|
+
.payment-account__plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); gap: 1rem; padding: 0; list-style: none; }
|
|
113
|
+
.payment-account__plan { min-width: 0; padding: 1rem; border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); border-radius: 12px; }
|
|
114
|
+
.payment-account :deep(.v-list-item-title), .payment-account :deep(.v-list-item-subtitle) { white-space: normal; overflow-wrap: anywhere; }
|
|
115
|
+
.payment-account :deep(.v-btn) { min-height: 48px; max-width: 100%; height: auto; white-space: normal; }
|
|
116
|
+
.payment-account :deep(.v-btn__content) { white-space: normal; }
|
|
117
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { chromium, expect } from "@playwright/test";
|
|
5
|
+
import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
|
|
6
|
+
|
|
7
|
+
test("billing component supports responsive layout, keyboard actions and recoverable states", {
|
|
8
|
+
skip: process.env.JSKIT_PAYMENTS_WEB_BROWSER_INTEGRATION !== "1", timeout: 120_000
|
|
9
|
+
}, async () => {
|
|
10
|
+
const runtime = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/payment-account/", import.meta.url)) });
|
|
11
|
+
let browser;
|
|
12
|
+
try {
|
|
13
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
14
|
+
const page = await browser.newPage();
|
|
15
|
+
const errors = [];
|
|
16
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
17
|
+
await page.goto(runtime.baseURL);
|
|
18
|
+
const billing = page.getByRole("region", { name: "Subscription and billing", exact: true });
|
|
19
|
+
const choose = billing.getByRole("button", { name: "Choose Studio plan" });
|
|
20
|
+
await expect(choose).toBeEnabled();
|
|
21
|
+
for (const [width, height] of [[390, 844], [768, 1024], [1024, 768], [1440, 1000], [1440, 500]]) {
|
|
22
|
+
await page.setViewportSize({ width, height });
|
|
23
|
+
await expect(billing).toBeVisible();
|
|
24
|
+
const layout = await billing.evaluate((element) => ({
|
|
25
|
+
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
26
|
+
targets: Array.from(element.querySelectorAll("button")).map((button) => button.getBoundingClientRect().height),
|
|
27
|
+
clipped: Array.from(element.querySelectorAll(".v-list-item-title, .v-list-item-subtitle, .v-btn__content")).some((item) => item.scrollWidth > item.clientWidth + 1 || item.scrollHeight > item.clientHeight + 1)
|
|
28
|
+
}));
|
|
29
|
+
assert.ok(layout.overflow <= 1, `${width}: horizontal overflow`);
|
|
30
|
+
assert.equal(layout.clipped, false, `${width}: clipped text`);
|
|
31
|
+
assert.ok(layout.targets.every((height) => height >= 48), `${width}: small target`);
|
|
32
|
+
}
|
|
33
|
+
await choose.focus();
|
|
34
|
+
await page.keyboard.press("Enter");
|
|
35
|
+
assert.deepEqual(await page.evaluate(() => window.paymentFixture.events.pop()), { name: "checkout", value: "studio" });
|
|
36
|
+
await billing.getByRole("button", { name: "Next billing page" }).click();
|
|
37
|
+
assert.deepEqual(await page.evaluate(() => window.paymentFixture.events.pop()), { name: "history", value: { collection: "transactions", after: "next-1" } });
|
|
38
|
+
await page.evaluate(() => { window.paymentFixture.state.pending = true; });
|
|
39
|
+
await expect(choose).toBeDisabled();
|
|
40
|
+
await expect(billing.getByRole("button", { name: "Manage billing" })).toBeDisabled();
|
|
41
|
+
await page.evaluate(() => { Object.assign(window.paymentFixture.state, { pending: false, canManage: false }); });
|
|
42
|
+
await expect(billing.getByText("A billing administrator can manage this account.")).toBeVisible();
|
|
43
|
+
await expect(choose).toBeDisabled();
|
|
44
|
+
await page.evaluate(() => { Object.assign(window.paymentFixture.state, { canManage: true, account: { balance: 0, features: [], hasCustomer: true, subscriptions: [{ id: "sub-1", planId: "studio", status: "past_due", periodEnd: 1800000000000 }] } }); });
|
|
45
|
+
await expect(billing.getByText("Payment overdue", { exact: true })).toBeVisible();
|
|
46
|
+
await expect(choose).toBeDisabled();
|
|
47
|
+
await billing.getByRole("button", { name: "Manage billing" }).focus();
|
|
48
|
+
await page.keyboard.press("Enter");
|
|
49
|
+
assert.equal(await page.evaluate(() => window.paymentFixture.events.pop().name), "portal");
|
|
50
|
+
await page.evaluate(() => { window.paymentFixture.state.historyError = "History unavailable"; });
|
|
51
|
+
await billing.getByRole("button", { name: "Retry billing history" }).click();
|
|
52
|
+
assert.equal(await page.evaluate(() => window.paymentFixture.events.pop().name), "retry-history");
|
|
53
|
+
await page.evaluate(() => { window.paymentFixture.state.canReadHistory = false; });
|
|
54
|
+
await expect(billing.getByRole("region", { name: "Billing history" })).toHaveCount(0);
|
|
55
|
+
await page.evaluate(() => { window.paymentFixture.state.loadError = "Billing unavailable"; });
|
|
56
|
+
await expect(billing.getByRole("alert")).toHaveText(/Billing unavailable/);
|
|
57
|
+
await billing.getByRole("button", { name: "Retry billing details" }).click();
|
|
58
|
+
assert.equal(await page.evaluate(() => window.paymentFixture.events.pop().name), "refresh");
|
|
59
|
+
await page.evaluate(() => { Object.assign(window.paymentFixture.state, { loadError: "", account: null, loading: true }); });
|
|
60
|
+
await expect(billing).toHaveAttribute("aria-busy", "true");
|
|
61
|
+
await expect(billing.locator(".v-skeleton-loader")).toBeVisible();
|
|
62
|
+
await expect(billing.getByRole("button")).toHaveCount(0);
|
|
63
|
+
assert.deepEqual(errors, []);
|
|
64
|
+
} finally {
|
|
65
|
+
await browser?.close();
|
|
66
|
+
await stopProcess(runtime);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createRenderer, defineComponent, h, nextTick, reactive } from 'vue';
|
|
6
|
+
import { compileScript, parse } from '@vue/compiler-sfc';
|
|
7
|
+
|
|
8
|
+
test('billing component preserves read state and guards checkout while leaving authority to the app', async () => {
|
|
9
|
+
const file = new URL('../src/client/components/PaymentAccount.vue', import.meta.url);
|
|
10
|
+
const { descriptor, errors } = parse(await readFile(file, 'utf8'), { filename: file.pathname });
|
|
11
|
+
assert.deepEqual(errors, []);
|
|
12
|
+
const compiled = compileScript(descriptor, { id: 'payment-account', inlineTemplate: true });
|
|
13
|
+
const directory = await mkdtemp(new URL('./.payment-component-', import.meta.url));
|
|
14
|
+
let app;
|
|
15
|
+
try {
|
|
16
|
+
const modulePath = `${directory}/component.mjs`;
|
|
17
|
+
await writeFile(modulePath, compiled.content);
|
|
18
|
+
const { default: PaymentAccount } = await import(pathToFileURL(modulePath));
|
|
19
|
+
const node = (type, text = '') => ({ type, text, props: {}, children: [], parent: null });
|
|
20
|
+
const renderer = createRenderer({
|
|
21
|
+
createElement: node, createText: (text) => node('text', text), createComment: (text) => node('comment', text),
|
|
22
|
+
setText: (item, text) => { item.text = text; },
|
|
23
|
+
setElementText: (item, text) => { item.text = text; item.children = []; },
|
|
24
|
+
patchProp: (item, key, _previous, value) => { item.props[key] = value; },
|
|
25
|
+
parentNode: (item) => item.parent,
|
|
26
|
+
nextSibling: (item) => item.parent?.children[item.parent.children.indexOf(item) + 1] ?? null,
|
|
27
|
+
insert(item, parent, anchor) {
|
|
28
|
+
if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1);
|
|
29
|
+
item.parent = parent;
|
|
30
|
+
const index = anchor ? parent.children.indexOf(anchor) : -1;
|
|
31
|
+
if (index < 0) parent.children.push(item); else parent.children.splice(index, 0, item);
|
|
32
|
+
},
|
|
33
|
+
remove(item) { if (item.parent) item.parent.children.splice(item.parent.children.indexOf(item), 1); }
|
|
34
|
+
});
|
|
35
|
+
const props = reactive({ account: { balance: 23, features: ['export'], subscriptions: [], hasCustomer: true },
|
|
36
|
+
plans: [{ id: 'pro', name: 'Pro', priceLabel: '$10 / month', features: ['export'], renewalCredits: 100, available: true }], canManage: true, pending: false, loading: false, loadError: '',
|
|
37
|
+
canReadHistory: false, history: null, historyLoading: false, historyError: '' });
|
|
38
|
+
const events = [];
|
|
39
|
+
const root = node('root');
|
|
40
|
+
app = renderer.createApp({ render: () => h(PaymentAccount, { ...props, onCheckout: (id) => events.push(['checkout', id]), onPortal: () => events.push(['portal']), onRefresh: () => events.push(['refresh']), onHistory: (request) => events.push(['history', request]), 'onRetry-history': () => events.push(['retry-history']) }) });
|
|
41
|
+
app.component('VBtn', defineComponent({ setup: (_props, { slots }) => () => h('button', {}, slots.default?.()) }));
|
|
42
|
+
app.component('VSkeletonLoader', defineComponent({ setup: () => () => h('div', 'Loading billing') }));
|
|
43
|
+
for (const name of ['v-list', 'v-list-item', 'v-list-item-title', 'v-list-item-subtitle']) {
|
|
44
|
+
app.component(name, defineComponent({ setup: (_props, { slots }) => () => h('div', {}, slots.default?.()) }));
|
|
45
|
+
}
|
|
46
|
+
app.mount(root);
|
|
47
|
+
const all = (item = root) => [item, ...item.children.flatMap((child) => all(child))];
|
|
48
|
+
const text = () => all().map((item) => item.text).join(' ');
|
|
49
|
+
const choose = () => all().find((item) => item.type === 'button' && item.props['aria-label'] === 'Choose Pro');
|
|
50
|
+
assert.match(text(), /23/);
|
|
51
|
+
choose().props.onClick();
|
|
52
|
+
assert.deepEqual(events, [['checkout', 'pro']]);
|
|
53
|
+
props.pending = true;
|
|
54
|
+
await nextTick();
|
|
55
|
+
assert.equal(choose().props.disabled, true);
|
|
56
|
+
choose().props.onClick();
|
|
57
|
+
assert.equal(events.length, 1);
|
|
58
|
+
props.pending = false;
|
|
59
|
+
props.account.subscriptions = [{ id: 'sub_a', planId: 'pro', status: 'past_due', periodEnd: 0 }];
|
|
60
|
+
await nextTick();
|
|
61
|
+
assert.match(text(), /Payment overdue/);
|
|
62
|
+
assert.equal(choose().props.disabled, true);
|
|
63
|
+
props.account.subscriptions = [];
|
|
64
|
+
props.canManage = false;
|
|
65
|
+
await nextTick();
|
|
66
|
+
choose().props.onClick();
|
|
67
|
+
assert.equal(events.length, 1);
|
|
68
|
+
props.loadError = 'Billing could not load';
|
|
69
|
+
await nextTick();
|
|
70
|
+
assert.equal(choose(), undefined);
|
|
71
|
+
assert.match(text(), /Billing could not load/);
|
|
72
|
+
all().find((item) => item.type === 'button').props.onClick();
|
|
73
|
+
assert.deepEqual(events.at(-1), ['refresh']);
|
|
74
|
+
props.loadError = '';
|
|
75
|
+
props.account = null;
|
|
76
|
+
props.loading = true;
|
|
77
|
+
await nextTick();
|
|
78
|
+
assert.match(text(), /Loading billing/);
|
|
79
|
+
assert.equal(choose(), undefined);
|
|
80
|
+
props.loading = false;
|
|
81
|
+
props.account = { balance: 0, features: [], subscriptions: [], hasCustomer: true };
|
|
82
|
+
await nextTick();
|
|
83
|
+
const button = (label) => all().find((item) => item.type === 'button' && all(item).some((child) => child.text === label));
|
|
84
|
+
assert.equal(button('View invoices and transactions'), undefined);
|
|
85
|
+
props.canReadHistory = true;
|
|
86
|
+
await nextTick();
|
|
87
|
+
button('View invoices and transactions').props.onClick();
|
|
88
|
+
assert.deepEqual(events.at(-1), ['history', { collection: 'transactions', after: null }]);
|
|
89
|
+
props.historyLoading = true;
|
|
90
|
+
await nextTick();
|
|
91
|
+
const count = events.length;
|
|
92
|
+
button('View subscription history').props.onClick();
|
|
93
|
+
assert.equal(events.length, count);
|
|
94
|
+
props.historyLoading = false;
|
|
95
|
+
props.history = { collection: 'transactions', items: [{ id: 'in_a', kind: 'invoice', status: 'open', createdAt: '2030-01-01T00:00:00Z', totalLabel: '$12.00', paidLabel: '$0.00' }], nextCursor: 'in_a' };
|
|
96
|
+
await nextTick();
|
|
97
|
+
assert.match(text(), /Invoice in_a/);
|
|
98
|
+
assert.match(text(), /Total: \$12.00/);
|
|
99
|
+
assert.match(text(), /totals do not confirm payment/);
|
|
100
|
+
button('Next billing page').props.onClick();
|
|
101
|
+
assert.deepEqual(events.at(-1), ['history', { collection: 'transactions', after: 'in_a' }]);
|
|
102
|
+
props.historyError = 'History unavailable';
|
|
103
|
+
await nextTick();
|
|
104
|
+
assert.doesNotMatch(text(), /Invoice in_a/);
|
|
105
|
+
assert.equal(button('Next billing page'), undefined);
|
|
106
|
+
button('Retry billing history').props.onClick();
|
|
107
|
+
assert.deepEqual(events.at(-1), ['retry-history']);
|
|
108
|
+
props.historyError = '';
|
|
109
|
+
props.history = { collection: 'transactions', items: [], nextCursor: null };
|
|
110
|
+
await nextTick();
|
|
111
|
+
assert.match(text(), /No billing records found/);
|
|
112
|
+
props.canReadHistory = false;
|
|
113
|
+
await nextTick();
|
|
114
|
+
assert.doesNotMatch(text(), /No billing records found/);
|
|
115
|
+
} finally { app?.unmount(); await rm(directory, { recursive: true, force: true }); }
|
|
116
|
+
});
|