@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 +246 -0
- package/README.md +62 -0
- package/dist/spa/assets/index-CT5Zon6P.css +1 -0
- package/dist/spa/assets/index-CvEbJEnq.js +41 -0
- package/dist/spa/assets/index-CvEbJEnq.js.map +1 -0
- package/dist/spa/index.html +20 -0
- package/package.json +64 -0
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))}}
|