@adonis-agora/payments-dashboard 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,246 @@
1
+ # @adonis-agora/payments-dashboard
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Seven things a real operator could not see once money was flowing. Each was found by asking a
8
+ question of a running install and discovering the console had no way to answer it.
9
+
10
+ **"Did THIS student's payment land?" had no answer.** `listPayments` returned
11
+ `externalReference` — the app's own join key, the one id an operator actually holds — and the
12
+ dashboard's payments handler dropped it before serialising, so the screen showed the gateway's
13
+ `pay_…` instead. There was no lookup by reference or by gateway id anywhere: the handler filtered
14
+ on `status`/`provider`, and so did the client. `PaymentListQuery` now carries
15
+ `externalReference`, `gatewayId` and `customerId` (all EXACT — `order-4` must never return
16
+ `order-42`), `GET <dashboard>/api/payments` takes `?reference=` / `?gatewayId=` / `?customerId=`,
17
+ the row payload carries `externalReference` and `refundedAmount`, and the screen has one search
18
+ box that tries the app's reference first and the gateway id second, because an operator pasting
19
+ from a support ticket does not know which of the two they are holding.
20
+
21
+ **There was no customers endpoint at all**, though `billing_customers` has held
22
+ `owner_type`/`owner_id` — written by every app calling `ensureCustomer` — since the first
23
+ release. That mapping is the only thing tying a payment to a person: the payment row carries
24
+ `cus_…` and nothing else. `GET <dashboard>/api/customers` lists it with `ownerType`/`ownerId`/
25
+ `gatewayId`/`provider` filters, the store gained `listCustomersByGatewayIds` so a page of payments
26
+ resolves its owners in one read rather than one query per row, and every payment row now carries
27
+ an `owner`. A gateway customer nobody mapped reads as **unmapped**, not as a blank — it means
28
+ charges are landing that this console can never attribute.
29
+
30
+ **No per-payment view, and the ledger could not be filtered.** `BillingListQuery` had no event
31
+ `type`, so "did a refund event ever arrive?" was unaskable. It does now, and `GET /api/providers`
32
+ reports the event types an install has actually received so the filter is built from data rather
33
+ than from the twenty types the package can emit. `GET /api/payments/:gatewayId` assembles what IS
34
+ knowable about one charge: current state, owner, the disputes filed against it, the ledger rows
35
+ whose stored delivery names it, and who refunded it. **It is not a history, and it does not claim
36
+ to be** — `billing_payments` is a single mutable row upserted in place, so what it used to be is
37
+ recorded nowhere. The ledger strand is a `CAST(payload AS TEXT) LIKE` scan and says so on the wire
38
+ (`events.matchedBy`) and on screen: it is unindexed, it can over-match, and it cannot see a
39
+ delivery that never stored the id. No history table was invented. The honest fix is a
40
+ `payment_gateway_id` column the **processor** fills on the way in, which is a write path this
41
+ change does not touch.
42
+
43
+ **The dispute-deadline check was structurally dead on a real install.** `disputes_due` was the
44
+ only dispute check, and both reads behind it require `evidence_due_by IS NOT NULL` — a column that
45
+ can only ever be filled by a gateway that publishes a deadline. On Asaas it comes from
46
+ `chargeback.deadlineToSendDisputeDocuments`, which the driver's own comments note no published
47
+ webhook example even contains. So a chargeback could be open with the money already pulled back
48
+ and `payments:health` reported healthy. A new **`open_disputes`** check counts every unanswered
49
+ dispute (`warning`, `open`, `under_review`) with no deadline required and **no threshold that can
50
+ turn it off** — an open chargeback is money already out of the account, so there is no horizon at
51
+ which it stops mattering. `BillingHealth` gained `openDisputes` (oldest first, because with no
52
+ deadline, age is the only priority left) and the health panel names them.
53
+
54
+ **A lost Asaas dispute could never be closed, so the alarm stayed red forever.** Asaas publishes
55
+ no lost-dispute event and the driver hardcodes `outcome: 'won'` on close, so `billing_disputes`
56
+ sat at `open` indefinitely; `listDisputesDueWithin` counts past-deadline rows on purpose, so the
57
+ check stayed red and a fifteen-minute cron logged the same failure until nobody read it — burying
58
+ every other finding with it. `POST <dashboard>/api/disputes/:gatewayId/resolve` records how a
59
+ dispute ENDED: a finished status (`lost`/`won`/`expired`/`canceled`), an outcome, a note, and WHO
60
+ said so. It sends nothing to a gateway, and the dialog says so twice: the decision was made at the
61
+ bank, this writes down which way it went. There is still no "fight" and no "accept" — that is a
62
+ business rule and it stays in the app's code.
63
+
64
+ **A rejected delivery left no record and no check covered it.** A bad signature, an unparsable
65
+ body or an unknown provider is answered `400` with nothing written anywhere, so a rotated webhook
66
+ token was invisible: zero events, zero failures, every check green. `unconfirmed_payments`
67
+ eventually fires at 2 h, but only for charges the app itself created — refund, chargeback and
68
+ dispute-closure deliveries produce no pending payment and simply vanished. A new
69
+ `billing_audit_events` table records them, a **`rejected_deliveries`** check counts them over 24 h,
70
+ and `GET <dashboard>/api/audit` surfaces them. **The rejection itself happens in
71
+ `providers/payments_provider.ts`, which this change does not touch: the endpoint must call
72
+ `store.recordAuditEvent({ action: 'webhook.rejected', provider, message })` on each `400` path for
73
+ the check to see anything.** Until it does, the store, the check and the screen are in place and
74
+ the count is zero.
75
+
76
+ **And a refund issued from the console left no audit trail.** The only record was a diagnostic
77
+ carrying a gateway id, a provider and an amount — and no actor, even though the dashboard's own
78
+ `enforce()` had already verified exactly who authorised the request. `enforce()` now returns that
79
+ user, `Deps.actor` carries it per request, and a successful refund writes an audit row naming the
80
+ person, the amount asked for and whether it was partial. A refund the gateway REFUSED writes
81
+ nothing: an audit of refunds that never happened is an audit nobody can trust. A console with no
82
+ `dashboardAuth` records `actor: null` — "unattributed", never an invented "system".
83
+
84
+ `billing_audit_events` is a new TABLE, so it needs nothing from the schema module's post-ship
85
+ ALTER phase: `CREATE TABLE IF NOT EXISTS` carries it to an existing install exactly as well as to a
86
+ fresh one. An install that upgrades the package before running the migration keeps working — every
87
+ audit write is additional to an action that already happened, so a missing table skips the note and
88
+ answers `null` rather than failing a refund the gateway already accepted.
89
+
90
+ The console gained two screens (**Customers** and **Activity**) and a per-payment detail view.
91
+ 1261 unit tests, 109 integration tests against real Postgres, 119 dashboard tests, typecheck and
92
+ lint clean. Every fix proven by mutation, including the JSONB cast — `jsonb LIKE text` is not an
93
+ operator in Postgres, and the in-memory store would have gone on passing without it.
94
+
95
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the **Disputes** screen to the billing console. `billing_disputes` had a full JSON API and no
96
+ UI, so the one table in this package with a deadline was the one you could not see.
97
+
98
+ A chargeback has a clock, and missing it loses the money **by default rather than on the merits**,
99
+ so the screen leads with the clock rather than with the log. Two panels, in this order:
100
+
101
+ - **Evidence windows closing** — `GET <api>/disputes?dueWithin=<hours>`: open disputes carrying a
102
+ deadline, soonest first, deadline as the leading column. The countdown is in hours (`in 5 hours`),
103
+ not days: rendering that as "today" is the difference between filing this morning and losing by
104
+ default. The horizon picker opens on **72 h** — the same horizon `payments:health` alerts on, so
105
+ the console and the cron agree about "soon" — and the count above the table is the server's
106
+ unbounded `dueWithin.total`, not the page it happened to fit.
107
+ - **All disputes** — the log, newest first, filterable by status and gateway.
108
+
109
+ The work list is deliberately **not** filterable by gateway. It is the one list whose whole job is
110
+ that nothing gets missed, and narrowing it to Stripe while an Asaas window shuts tonight is exactly
111
+ the failure it exists to prevent.
112
+
113
+ Three nullable facts are rendered as the things they mean, not as missing data:
114
+
115
+ - a window **already past its deadline still appears**, marked `past due` with how long ago — it is
116
+ still open and still unanswered, and going quiet the moment it expires reads as resolved;
117
+ - a dispute with **no deadline** is absent from the work list and says _the gateway sends no
118
+ deadline_ in the log, rather than showing a dash. Several gateways send no date, and Woovi's
119
+ three-day rule is policy rather than a field;
120
+ - a dispute with **no amount** (Stripe's early fraud warning carries none) says so instead of
121
+ rendering `R$ 0,00`.
122
+
123
+ `warning` does not look like a chargeback: nothing has been pulled back, a refund still prevents the
124
+ debit, and the row says _no money moved_ in words rather than leaving it to a hue. The seven
125
+ `DisputeStatus` values get their own hues, and an unmodelled gateway status falls back to grey
126
+ rather than borrowing one.
127
+
128
+ **The screen is read-only and stays that way.** No "fight this", no "accept", no "refund": whether
129
+ contesting is worth it turns on margin, customer value, the dispute fee and the chargeback ratio
130
+ that puts a merchant into a card network's monitoring programme. That is a business rule that lives
131
+ in your app's code, and a console button invites someone to press it without any of that context.
132
+ The JSON API has no action route for disputes, and a test asserts the client grows no method that
133
+ implies one.
134
+
135
+ Also in the console: the health panel's fourth check (`disputes_due`) now has somewhere to go — its
136
+ button opens the Disputes screen on exactly the rows it counted — and the panel names **which**
137
+ windows are closing, with gateway, dispute id and countdown, instead of only how many.
138
+
139
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Turn the billing dashboard into a management console: health panel, subscriptions screen, gateway filter, and the first two actions.
140
+
141
+ The console could tell you what happened; it could not tell you what needed doing, and it could not
142
+ do anything about it. Four changes:
143
+
144
+ - **A health panel at the top of the Overview**, above the revenue tiles. `billingHealth()` — the
145
+ three silent failures of a billing install (events claimed and never finished, events the
146
+ dispatcher gave up on, charges created that never confirmed) — existed only behind
147
+ `node ace payments:health`. It is now `GET <path>/api/health` and the first thing on the page: a
148
+ quiet green line when the install is clean, and when it is not, the count, what it means, which
149
+ provider and event type is failing, and a link straight to those rows.
150
+ - **A subscriptions screen** (`GET <path>/api/subscriptions`), defaulting to `past_due`, showing
151
+ plan, customer, trial end and period end. `paused` is rendered as its own state with its own hue
152
+ and an explicit "not billing" — a paused subscriber is not paying, and reading them as active
153
+ grants access to someone who is not.
154
+ - **A gateway filter** on payments, subscriptions and webhook events, built from
155
+ `GET <path>/api/providers` — what your data actually contains, not the eighteen drivers the
156
+ package ships. A filtered page reports when its scan stopped short rather than showing a
157
+ confident empty result.
158
+ - **Two actions**, the console's first writes. `POST <path>/api/payments/:gatewayId/refund`
159
+ (optional partial amount, confirmed in the UI with the amount and the customer, refused before the
160
+ call for a gateway with no refund API, reporting the gateway's own message when it refuses) and
161
+ `POST <path>/api/webhook-events/:gatewayEventId/retry`, which re-runs a failed event through your
162
+ own webhook handlers. Both are `POST` only and both run through the existing `authorize` guard and
163
+ the `dashboardAuth` session.
164
+
165
+ Also: the payments filter now offers `authorized` and `disputed` (both were reachable statuses with
166
+ no way to filter for them), and the SPA's money formatter was aligned with `src/money.ts` — it was
167
+ missing the three-decimal currencies entirely, so a KWD amount rendered 10× too large.
168
+
169
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Revenue was reported gross with nothing saying so, and a partial refund was invisible in it.
170
+
171
+ `billing_payments.refunded_amount` landed in the previous release precisely so a PARTIAL refund
172
+ could be recorded without mangling `amount` or `status`: a R$10 refund on a R$100 charge leaves the
173
+ row `paid` at `amount: 10000, refundedAmount: 1000`, and the net is one subtraction. But every
174
+ aggregate went on summing `amount` alone. `revenue()` and `billingOverview`'s revenue metric counted
175
+ that charge at its full R$100, the console printed it under the single word **Revenue**, and nothing
176
+ on the screen admitted the number was gross. Money that had already gone back to the cardholder was
177
+ being reported as earned.
178
+
179
+ **`revenue()` is unchanged and still gross.** It was the only revenue figure this library had for
180
+ two releases and apps read it; redefining it in a release that already carries breaking changes
181
+ would have moved numbers on other people's screens with no error to announce it. Gross and net are
182
+ both legitimate — gross is what you collected, net is what you kept — so the fix publishes both.
183
+
184
+ **`store.netRevenue({ from, to })` is new**, on the `BillingStore` SPI and on BOTH implementations
185
+ (`LucidBillingStore` and the `InMemoryBillingStore` in `/testing`). It takes exactly the rows and
186
+ the window `revenue()` takes — `status = 'paid'`, windowed on `paid_at` — and sums
187
+ `amount - COALESCE(refunded_amount, 0)` instead of `amount`. Integer minor units throughout, never a
188
+ division. Two details it has to get right, both proven against real Postgres: `refunded_amount` is
189
+ `NULL` on every row written before the column existed and `amount - NULL` is `NULL` in SQL, which
190
+ `SUM` spreads across the whole window — so one legacy row would report zero net revenue for an
191
+ install that took a million; and a `BIGINT` sum arrives from node-postgres as a **string**, so it is
192
+ consumed through `Number` like every other amount in the store. On an install whose table predates
193
+ the column, `netRevenue()` answers exactly what `revenue()` does, because no refund was ever
194
+ recorded to subtract.
195
+
196
+ **`billingOverview` now returns two money metrics**, `revenue` (label `Revenue, gross (cents)`) and
197
+ `net_revenue` (label `Revenue, net of refunds (cents)`). The `revenue` key keeps its key, its
198
+ position and its value; only its label gained the word "gross". If you render the metric list by
199
+ key, add `net_revenue` to whatever you treat as money — a money metric rendered as a plain count is
200
+ the figure wrong by 100×.
201
+
202
+ **The console shows both**, as **Revenue (gross)** ("Paid payments settled in this window. Refunds
203
+ NOT subtracted.") and **Revenue (net)** ("The same payments, minus what was refunded. This is what
204
+ you kept."), each labelled in words so neither can be read as the other.
205
+
206
+ Nothing else that reports money inherited the blindness: `billingHealth` and `payments:sync` report
207
+ counts, not amounts, and the payments list and per-payment view already showed `refundedAmount`
208
+ beside the charge. `meteredBill`'s `total` is a projected overage charge rather than settled
209
+ revenue, so refunds do not apply to it.
210
+
211
+ - [#5](https://github.com/DavideCarvalho/adonis-agora-payments/pull/5) [`1db1891`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/1db1891cad2481c468169de8398ffb307befb01a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add `@adonis-agora/payments-dashboard`, the billing console for `@adonis-agora/payments`.
212
+
213
+ A React SPA (Vite + Tailwind + TanStack Query) with three read-only screens — an overview of
214
+ `billingOverview()`'s revenue/subscription/usage aggregates over a selectable window, a payment list,
215
+ and the webhook-event ledger that surfaces `failed` rows with the handler error that caused them.
216
+ Everything is a `BillingStore` read; the console makes no gateway calls and has no control actions.
217
+
218
+ The Adonis half ships inside `@adonis-agora/payments` and mirrors `@adonis-agora/durable`'s dashboard:
219
+ a `dashboard_provider` that serves the built bundle from disk (never importing the SPA package), the
220
+ `BASE_PLACEHOLDER` rewrite that lets one bundle mount at any path, framework-light JSON handlers, and
221
+ the same optional `dashboardAuth` session gate. New entry points: `@adonis-agora/payments/dashboard`
222
+ and `@adonis-agora/payments/dashboard_provider`, plus a published `config/payments_dashboard.ts`.
223
+
224
+ The dashboard is off-able entirely (`enabled: false` registers no routes at all) and defaults to the
225
+ same safe auth posture as the durable console: open outside production, and in production a bearer
226
+ token equal to `PAYMENTS_DASHBOARD_TOKEN`, denying when it is unset.
227
+
228
+ Also adds two narrow reads to the `BillingStore` contract — `listPayments(query)` and
229
+ `listWebhookEvents(query)` — implemented in both `LucidBillingStore` and `InMemoryBillingStore`. They
230
+ return a normalized plain shape rather than the implementation's row type, so a reader never depends
231
+ on Lucid.
232
+
233
+ ### Patch Changes
234
+
235
+ - [#6](https://github.com/DavideCarvalho/adonis-agora-payments/pull/6) [`5d2a895`](https://github.com/DavideCarvalho/adonis-agora-payments/commit/5d2a895a41736ea924c1851c71e59a29034b299f) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The console's two actions now echo the host app's CSRF token
236
+
237
+ The dashboard is mounted inside a host application, and an AdonisJS app running
238
+ `@adonisjs/shield` guards every state-changing route with CSRF. The SPA sent no token, so
239
+ `POST …/refund` and `POST …/webhook-events/:id/retry` were rejected before they reached the
240
+ dashboard's own authorization — the button did nothing, and nothing on screen said why.
241
+
242
+ Shield publishes the token as an `XSRF-TOKEN` cookie for exactly this purpose. Both POSTs
243
+ now read it and send `x-xsrf-token`. No cookie means no header, which is the right answer
244
+ for a host that does not run shield: an empty token would be worse than none.
245
+
246
+ Reads are untouched — CSRF only guards mutations.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @adonis-agora/payments-dashboard
2
+
3
+ The billing console for [`@adonis-agora/payments`](https://www.npmjs.com/package/@adonis-agora/payments) — a
4
+ React SPA served by a thin AdonisJS provider that ships inside the main package.
5
+
6
+ Five screens, all driven by the `BillingStore` the app already resolved. Only two things in the whole
7
+ console leave your app — refunding a payment and retrying a failed webhook event:
8
+
9
+ - **Overview** — `billingHealth()` first, then `billingOverview()` for a selectable window: revenue,
10
+ active subscriptions (which includes `trialing`), and metered usage per meter.
11
+ - **Payments** — a page of `billing_payments`, newest first, filterable by status and gateway.
12
+ Refund from here.
13
+ - **Subscriptions** — opens on `past_due`: whose payment is failing and whose access is about to
14
+ lapse. `paused` is rendered as its own state, never as a shade of `active`.
15
+ - **Disputes** — the evidence windows closing soonest first, then the full chargeback log. **Read
16
+ only, deliberately**: whether a dispute is worth contesting or cheaper to refund is a business
17
+ rule that belongs in your app's code, so there is no button for it here (and no API route behind
18
+ one). The screen exists so nobody misses a window.
19
+ - **Webhook events** — the idempotency ledger. A `failed` row means a handler threw and the
20
+ dispatcher gave up, so the event's effect never happened; the handler's error is shown in full.
21
+ Retry from here.
22
+
23
+ ## Install
24
+
25
+ You do not install this package directly. `@adonis-agora/payments` bundles the built SPA and serves it
26
+ from disk; this package only exists so the bundle is built in the workspace and versioned on its own.
27
+
28
+ ```sh
29
+ node ace configure @adonis-agora/payments
30
+ ```
31
+
32
+ That registers `@adonis-agora/payments/dashboard_provider` and publishes `config/payments_dashboard.ts`.
33
+
34
+ ## Configuration
35
+
36
+ ```ts
37
+ // config/payments_dashboard.ts
38
+ import { defineConfig } from '@adonis-agora/payments/dashboard'
39
+
40
+ export default defineConfig({
41
+ enabled: true, // false => no routes are registered at all
42
+ path: '/payments-dashboard', // the SPA mounts here; the JSON API at <path>/api
43
+ currency: 'BRL', // how the edge formats the integer cents it receives
44
+ })
45
+ ```
46
+
47
+ **Auth defaults to the same posture as `@adonis-agora/durable`'s console**: open outside production;
48
+ in production it requires a bearer token equal to `PAYMENTS_DASHBOARD_TOKEN`, and denies everything
49
+ when that variable is unset. Replace `authorize` with your own guard, and/or add the optional
50
+ `dashboardAuth` session gate (a built-in login page and/or an "open the console from your app"
51
+ endpoint) on top of it.
52
+
53
+ ## Money
54
+
55
+ Amounts are integer cents everywhere — in the store, in `billingOverview()`, and on the wire. The only
56
+ division by 100 in the whole console is in `src/app/money.ts`, at render.
57
+
58
+ ## Mounting anywhere
59
+
60
+ `vite build` bakes a placeholder base (`/__PAYMENTS_DASHBOARD__/`) into the asset URLs, which the
61
+ AdonisJS provider rewrites at serve time to whatever `path` is configured. One built bundle mounts at
62
+ any prefix with no rebuild.
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.z-10{z-index:10}.z-50{z-index:50}.m-0{margin:0}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.min-h-full{min-height:100%}.w-32{width:8rem}.w-48{width:12rem}.w-full{width:100%}.min-w-\[16rem\]{min-width:16rem}.min-w-\[3ch\]{min-width:3ch}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.grow{flex-grow:1}.grid-cols-\[auto\,1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1\.5{row-gap:.375rem}.gap-y-2{row-gap:.5rem}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-bad\/25{border-color:color-mix(in srgb,var(--bad) 25%,transparent)}.border-bad\/30{border-color:color-mix(in srgb,var(--bad) 30%,transparent)}.border-bad\/40{border-color:color-mix(in srgb,var(--bad) 40%,transparent)}.border-bad\/50{border-color:color-mix(in srgb,var(--bad) 50%,transparent)}.border-good\/30{border-color:color-mix(in srgb,var(--good) 30%,transparent)}.border-good\/40{border-color:color-mix(in srgb,var(--good) 40%,transparent)}.border-indigo-500\/30{border-color:#6366f14d}.border-line{--tw-border-opacity: 1;border-color:color-mix(in srgb,var(--line) calc(var(--tw-border-opacity, 1) * 100%),transparent)}.border-line-soft{--tw-border-opacity: 1;border-color:color-mix(in srgb,var(--line-soft) calc(var(--tw-border-opacity, 1) * 100%),transparent)}.border-rose-500\/40{border-color:#f43f5e66}.border-sky-500\/30{border-color:#0ea5e94d}.border-transparent{border-color:transparent}.border-warn\/30{border-color:color-mix(in srgb,var(--warn) 30%,transparent)}.border-warn\/40{border-color:color-mix(in srgb,var(--warn) 40%,transparent)}.bg-bad\/10{background-color:color-mix(in srgb,var(--bad) 10%,transparent)}.bg-bad\/15{background-color:color-mix(in srgb,var(--bad) 15%,transparent)}.bg-bad\/\[0\.04\]{background-color:color-mix(in srgb,var(--bad) 4%,transparent)}.bg-bad\/\[0\.05\]{background-color:color-mix(in srgb,var(--bad) 5%,transparent)}.bg-bad\/\[0\.06\]{background-color:color-mix(in srgb,var(--bad) 6%,transparent)}.bg-black\/70{background-color:#000000b3}.bg-brand\/15{background-color:color-mix(in srgb,var(--accent) 15%,transparent)}.bg-good\/10{background-color:color-mix(in srgb,var(--good) 10%,transparent)}.bg-good\/\[0\.06\]{background-color:color-mix(in srgb,var(--good) 6%,transparent)}.bg-indigo-500\/10{background-color:#6366f11a}.bg-panel{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.bg-panel-2{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel-2) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-sky-500\/10{background-color:#0ea5e91a}.bg-warn\/\[0\.06\]{background-color:color-mix(in srgb,var(--warn) 6%,transparent)}.bg-warn\/\[0\.08\]{background-color:color-mix(in srgb,var(--warn) 8%,transparent)}.bg-zinc-800\/40{background-color:#27272a66}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-0{padding-left:0;padding-right:0}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-3{padding-bottom:.75rem}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.tracking-wider{letter-spacing:.05em}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-brand{--tw-text-opacity: 1;color:color-mix(in srgb,var(--accent) calc(var(--tw-text-opacity, 1) * 100%),transparent)}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-indigo-300{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.text-inherit{color:inherit}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-rose-300{--tw-text-opacity: 1;color:rgb(253 164 175 / var(--tw-text-opacity, 1))}.text-sky-300{--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.text-zinc-100{--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.text-zinc-200{--tw-text-opacity: 1;color:rgb(228 228 231 / var(--tw-text-opacity, 1))}.text-zinc-300{--tw-text-opacity: 1;color:rgb(212 212 216 / var(--tw-text-opacity, 1))}.text-zinc-400{--tw-text-opacity: 1;color:rgb(161 161 170 / var(--tw-text-opacity, 1))}.text-zinc-500{--tw-text-opacity: 1;color:rgb(113 113 122 / var(--tw-text-opacity, 1))}.text-zinc-600{--tw-text-opacity: 1;color:rgb(82 82 91 / var(--tw-text-opacity, 1))}.opacity-80{opacity:.8}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--bg: #09090b;--panel: #0c0c0f;--panel-2: #101017;--line: #1c1c22;--line-soft: #16161a;--text: #e7e7ea;--muted: #76767f;--good: #34d399;--warn: #fbbf24;--bad: #f87171;--live: #60a5fa;--accent: #22d3ee}*{box-sizing:border-box}html,body,#root{height:100%}body{margin:0;background:var(--bg);color:var(--text);font-family:Space Grotesk,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}.app-bg{position:fixed;top:0;right:0;bottom:0;left:0;z-index:0;pointer-events:none;background-image:radial-gradient(900px 400px at 78% -8%,color-mix(in srgb,var(--accent) 8%,transparent),transparent 70%),linear-gradient(to right,var(--line-soft) 1px,transparent 1px),linear-gradient(to bottom,var(--line-soft) 1px,transparent 1px);background-size:100% 100%,44px 44px,44px 44px;-webkit-mask-image:linear-gradient(to bottom,black,black 60%,transparent);mask-image:linear-gradient(to bottom,black,black 60%,transparent);opacity:.5}.mono{font-family:JetBrains Mono,ui-monospace,monospace}.tnum{font-variant-numeric:tabular-nums}.s-paid{color:var(--good)}.s-pending{color:#94a3b8}.s-failed{color:var(--bad)}.s-refunded{color:#a78bfa}.s-canceled{color:#71717a}.s-authorized{color:var(--warn)}.s-disputed{color:#fb7185}.s-active{color:var(--good)}.s-trialing{color:var(--live)}.s-past_due{color:var(--bad)}.s-paused,.s-incomplete{color:var(--warn)}.s-ended{color:#71717a}.s-warning{color:var(--warn)}.s-open{color:var(--bad)}.s-under_review{color:var(--live)}.s-won{color:var(--good)}.s-lost,.s-expired{color:#fb7185}.s-received{color:var(--live)}.s-processed{color:var(--good)}.s-unknown{color:var(--muted)}.dot{width:7px;height:7px;border-radius:999px;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,currentColor 18%,transparent)}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}.rise{animation:rise .32s cubic-bezier(.2,.7,.2,1) both}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#26262c;border:3px solid var(--bg);border-radius:999px}.placeholder\:text-zinc-600::-moz-placeholder{--tw-text-opacity: 1;color:rgb(82 82 91 / var(--tw-text-opacity, 1))}.placeholder\:text-zinc-600::placeholder{--tw-text-opacity: 1;color:rgb(82 82 91 / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.hover\:bg-panel:hover{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.hover\:bg-panel-2:hover{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel-2) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.hover\:text-zinc-100:hover{--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.hover\:text-zinc-200:hover{--tw-text-opacity: 1;color:rgb(228 228 231 / var(--tw-text-opacity, 1))}.focus\:border-brand:focus{--tw-border-opacity: 1;border-color:color-mix(in srgb,var(--accent) calc(var(--tw-border-opacity, 1) * 100%),transparent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.enabled\:hover\:bg-bad\/25:hover:enabled{background-color:color-mix(in srgb,var(--bad) 25%,transparent)}.enabled\:hover\:bg-panel:hover:enabled{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.enabled\:hover\:bg-panel-2:hover:enabled{--tw-bg-opacity: 1;background-color:color-mix(in srgb,var(--panel-2) calc(var(--tw-bg-opacity, 1) * 100%),transparent)}.enabled\:hover\:text-zinc-100:hover:enabled{--tw-text-opacity: 1;color:rgb(244 244 245 / var(--tw-text-opacity, 1))}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}