@86d-app/payments 0.0.3
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/AGENTS.md +47 -0
- package/README.md +261 -0
- package/package.json +49 -0
- package/src/__tests__/service-impl.test.ts +589 -0
- package/src/admin/components/index.tsx +1 -0
- package/src/admin/components/payments-admin.mdx +27 -0
- package/src/admin/components/payments-admin.tsx +521 -0
- package/src/admin/endpoints/create-refund.ts +25 -0
- package/src/admin/endpoints/get-intent.ts +16 -0
- package/src/admin/endpoints/index.ts +11 -0
- package/src/admin/endpoints/list-intents.ts +36 -0
- package/src/admin/endpoints/list-refunds.ts +15 -0
- package/src/index.ts +58 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +97 -0
- package/src/service-impl.ts +331 -0
- package/src/service.ts +160 -0
- package/src/store/endpoints/cancel-intent.ts +16 -0
- package/src/store/endpoints/confirm-intent.ts +16 -0
- package/src/store/endpoints/create-intent.ts +29 -0
- package/src/store/endpoints/delete-method.ts +17 -0
- package/src/store/endpoints/get-intent.ts +16 -0
- package/src/store/endpoints/index.ts +15 -0
- package/src/store/endpoints/list-methods.ts +17 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import PaymentsAdminTemplate from "./payments-admin.mdx";
|
|
6
|
+
|
|
7
|
+
interface PaymentIntent {
|
|
8
|
+
id: string;
|
|
9
|
+
providerIntentId?: string | null;
|
|
10
|
+
customerId?: string | null;
|
|
11
|
+
email?: string | null;
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
status: string;
|
|
15
|
+
orderId?: string | null;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatPrice(cents: number, currency = "USD"): string {
|
|
20
|
+
return new Intl.NumberFormat("en-US", {
|
|
21
|
+
style: "currency",
|
|
22
|
+
currency,
|
|
23
|
+
}).format(cents / 100);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function timeAgo(dateStr: string): string {
|
|
27
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
28
|
+
const mins = Math.floor(diff / 60000);
|
|
29
|
+
if (mins < 1) return "just now";
|
|
30
|
+
if (mins < 60) return `${mins}m ago`;
|
|
31
|
+
const hrs = Math.floor(mins / 60);
|
|
32
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
33
|
+
const days = Math.floor(hrs / 24);
|
|
34
|
+
return `${days}d ago`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INTENT_STATUS_COLORS: Record<string, string> = {
|
|
38
|
+
pending:
|
|
39
|
+
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
40
|
+
processing:
|
|
41
|
+
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
42
|
+
succeeded:
|
|
43
|
+
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
44
|
+
failed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
45
|
+
cancelled: "bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400",
|
|
46
|
+
refunded:
|
|
47
|
+
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const REFUND_STATUS_COLORS: Record<string, string> = {
|
|
51
|
+
pending:
|
|
52
|
+
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
53
|
+
succeeded:
|
|
54
|
+
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
55
|
+
failed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function usePaymentsAdminApi() {
|
|
59
|
+
const client = useModuleClient();
|
|
60
|
+
return {
|
|
61
|
+
listIntents: client.module("payments").admin["/admin/payments"],
|
|
62
|
+
getIntent: client.module("payments").admin["/admin/payments/:id"],
|
|
63
|
+
refundIntent: client.module("payments").admin["/admin/payments/:id/refund"],
|
|
64
|
+
listRefunds: client.module("payments").admin["/admin/payments/:id/refunds"],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function RefundModal({
|
|
69
|
+
intent,
|
|
70
|
+
onClose,
|
|
71
|
+
onSuccess,
|
|
72
|
+
}: {
|
|
73
|
+
intent: PaymentIntent;
|
|
74
|
+
onClose: () => void;
|
|
75
|
+
onSuccess: () => void;
|
|
76
|
+
}) {
|
|
77
|
+
const api = usePaymentsAdminApi();
|
|
78
|
+
const [amount, setAmount] = useState("");
|
|
79
|
+
const [reason, setReason] = useState("");
|
|
80
|
+
const [error, setError] = useState("");
|
|
81
|
+
|
|
82
|
+
const refundMutation = api.refundIntent.useMutation({
|
|
83
|
+
onSuccess: () => {
|
|
84
|
+
void api.listIntents.invalidate();
|
|
85
|
+
onSuccess();
|
|
86
|
+
},
|
|
87
|
+
onError: () => {
|
|
88
|
+
setError("Refund failed. Please try again.");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setError("");
|
|
95
|
+
|
|
96
|
+
// biome-ignore lint/suspicious/noExplicitAny: building dynamic body for refund endpoint
|
|
97
|
+
const body: Record<string, any> = { params: { id: intent.id } };
|
|
98
|
+
if (reason) body.reason = reason;
|
|
99
|
+
if (amount) {
|
|
100
|
+
const parsed = Math.round(Number.parseFloat(amount) * 100);
|
|
101
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
102
|
+
setError("Invalid amount");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
body.amount = parsed;
|
|
106
|
+
}
|
|
107
|
+
refundMutation.mutate(body);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const maxAmount = intent.amount / 100;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
114
|
+
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl">
|
|
115
|
+
<div className="flex items-center justify-between border-border border-b px-6 py-4">
|
|
116
|
+
<h2 className="font-semibold text-foreground text-lg">
|
|
117
|
+
Issue Refund
|
|
118
|
+
</h2>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={onClose}
|
|
122
|
+
className="rounded-md p-1 text-muted-foreground hover:bg-muted"
|
|
123
|
+
>
|
|
124
|
+
<svg
|
|
125
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
126
|
+
width="16"
|
|
127
|
+
height="16"
|
|
128
|
+
viewBox="0 0 24 24"
|
|
129
|
+
fill="none"
|
|
130
|
+
stroke="currentColor"
|
|
131
|
+
strokeWidth="2"
|
|
132
|
+
strokeLinecap="round"
|
|
133
|
+
strokeLinejoin="round"
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
>
|
|
136
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
137
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
138
|
+
</svg>
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="px-6 py-4">
|
|
143
|
+
<div className="mb-4 rounded-lg bg-muted/50 p-3 text-sm">
|
|
144
|
+
<div className="flex justify-between">
|
|
145
|
+
<span className="text-muted-foreground">Intent ID</span>
|
|
146
|
+
<span className="font-mono text-foreground text-xs">
|
|
147
|
+
{intent.id.slice(0, 8)}…
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="mt-1 flex justify-between">
|
|
151
|
+
<span className="text-muted-foreground">Original amount</span>
|
|
152
|
+
<span className="font-medium text-foreground">
|
|
153
|
+
{formatPrice(intent.amount, intent.currency)}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
159
|
+
<div>
|
|
160
|
+
<label
|
|
161
|
+
htmlFor="refund-amount"
|
|
162
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
163
|
+
>
|
|
164
|
+
Refund amount{" "}
|
|
165
|
+
<span className="font-normal text-muted-foreground">
|
|
166
|
+
(leave blank for full refund)
|
|
167
|
+
</span>
|
|
168
|
+
</label>
|
|
169
|
+
<div className="relative">
|
|
170
|
+
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground text-sm">
|
|
171
|
+
$
|
|
172
|
+
</span>
|
|
173
|
+
<input
|
|
174
|
+
id="refund-amount"
|
|
175
|
+
type="number"
|
|
176
|
+
step="0.01"
|
|
177
|
+
min="0.01"
|
|
178
|
+
max={maxAmount}
|
|
179
|
+
value={amount}
|
|
180
|
+
onChange={(e) => setAmount(e.target.value)}
|
|
181
|
+
placeholder={String(maxAmount.toFixed(2))}
|
|
182
|
+
className="h-9 w-full rounded-md border border-border bg-background pr-3 pl-7 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div>
|
|
188
|
+
<label
|
|
189
|
+
htmlFor="refund-reason"
|
|
190
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
191
|
+
>
|
|
192
|
+
Reason{" "}
|
|
193
|
+
<span className="font-normal text-muted-foreground">
|
|
194
|
+
(optional)
|
|
195
|
+
</span>
|
|
196
|
+
</label>
|
|
197
|
+
<input
|
|
198
|
+
id="refund-reason"
|
|
199
|
+
type="text"
|
|
200
|
+
value={reason}
|
|
201
|
+
onChange={(e) => setReason(e.target.value)}
|
|
202
|
+
placeholder="e.g. customer request, duplicate charge"
|
|
203
|
+
className="h-9 w-full rounded-md border border-border bg-background px-3 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{error && (
|
|
208
|
+
<p className="rounded-md bg-red-50 px-3 py-2 text-red-600 text-sm dark:bg-red-900/20 dark:text-red-400">
|
|
209
|
+
{error}
|
|
210
|
+
</p>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={onClose}
|
|
217
|
+
className="rounded-md border border-border px-4 py-2 text-foreground text-sm hover:bg-muted"
|
|
218
|
+
>
|
|
219
|
+
Cancel
|
|
220
|
+
</button>
|
|
221
|
+
<button
|
|
222
|
+
type="submit"
|
|
223
|
+
disabled={refundMutation.isPending}
|
|
224
|
+
className="rounded-md bg-destructive px-4 py-2 font-medium text-destructive-foreground text-sm hover:bg-destructive/90 disabled:opacity-50"
|
|
225
|
+
>
|
|
226
|
+
{refundMutation.isPending ? "Processing…" : "Issue refund"}
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</form>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function IntentsTab() {
|
|
237
|
+
const api = usePaymentsAdminApi();
|
|
238
|
+
const [page, setPage] = useState(1);
|
|
239
|
+
const [statusFilter, setStatusFilter] = useState("");
|
|
240
|
+
const [refundTarget, setRefundTarget] = useState<PaymentIntent | null>(null);
|
|
241
|
+
const pageSize = 20;
|
|
242
|
+
|
|
243
|
+
const queryInput: Record<string, string> = {
|
|
244
|
+
page: String(page),
|
|
245
|
+
limit: String(pageSize),
|
|
246
|
+
};
|
|
247
|
+
if (statusFilter) queryInput.status = statusFilter;
|
|
248
|
+
|
|
249
|
+
const { data, isLoading: loading } = api.listIntents.useQuery(queryInput) as {
|
|
250
|
+
data: { intents: PaymentIntent[] } | undefined;
|
|
251
|
+
isLoading: boolean;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const intents = data?.intents ?? [];
|
|
255
|
+
const total = intents.length;
|
|
256
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
257
|
+
|
|
258
|
+
const handleRefundSuccess = () => {
|
|
259
|
+
setRefundTarget(null);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<>
|
|
264
|
+
<div className="mb-4 flex flex-wrap gap-3">
|
|
265
|
+
<select
|
|
266
|
+
value={statusFilter}
|
|
267
|
+
onChange={(e) => {
|
|
268
|
+
setStatusFilter(e.target.value);
|
|
269
|
+
setPage(1);
|
|
270
|
+
}}
|
|
271
|
+
className="h-9 rounded-md border border-border bg-background px-3 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
272
|
+
>
|
|
273
|
+
<option value="">All statuses</option>
|
|
274
|
+
<option value="pending">Pending</option>
|
|
275
|
+
<option value="processing">Processing</option>
|
|
276
|
+
<option value="succeeded">Succeeded</option>
|
|
277
|
+
<option value="failed">Failed</option>
|
|
278
|
+
<option value="cancelled">Cancelled</option>
|
|
279
|
+
<option value="refunded">Refunded</option>
|
|
280
|
+
</select>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
284
|
+
<table className="w-full">
|
|
285
|
+
<thead>
|
|
286
|
+
<tr className="border-border border-b bg-muted/50">
|
|
287
|
+
<th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
288
|
+
ID
|
|
289
|
+
</th>
|
|
290
|
+
<th className="hidden px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide sm:table-cell">
|
|
291
|
+
Customer
|
|
292
|
+
</th>
|
|
293
|
+
<th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
294
|
+
Status
|
|
295
|
+
</th>
|
|
296
|
+
<th className="px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
297
|
+
Amount
|
|
298
|
+
</th>
|
|
299
|
+
<th className="hidden px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide lg:table-cell">
|
|
300
|
+
Date
|
|
301
|
+
</th>
|
|
302
|
+
<th className="px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
303
|
+
Actions
|
|
304
|
+
</th>
|
|
305
|
+
</tr>
|
|
306
|
+
</thead>
|
|
307
|
+
<tbody className="divide-y divide-border">
|
|
308
|
+
{loading ? (
|
|
309
|
+
Array.from({ length: 5 }).map((_, i) => (
|
|
310
|
+
<tr key={`skeleton-${i}`}>
|
|
311
|
+
{Array.from({ length: 6 }).map((_, j) => (
|
|
312
|
+
<td key={`skeleton-cell-${j}`} className="px-4 py-3">
|
|
313
|
+
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
314
|
+
</td>
|
|
315
|
+
))}
|
|
316
|
+
</tr>
|
|
317
|
+
))
|
|
318
|
+
) : intents.length === 0 ? (
|
|
319
|
+
<tr>
|
|
320
|
+
<td colSpan={6} className="px-4 py-12 text-center">
|
|
321
|
+
<p className="font-medium text-foreground text-sm">
|
|
322
|
+
No payment intents found
|
|
323
|
+
</p>
|
|
324
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
325
|
+
Payment intents will appear here once customers initiate
|
|
326
|
+
checkout
|
|
327
|
+
</p>
|
|
328
|
+
</td>
|
|
329
|
+
</tr>
|
|
330
|
+
) : (
|
|
331
|
+
intents.map((intent) => (
|
|
332
|
+
<tr
|
|
333
|
+
key={intent.id}
|
|
334
|
+
className="transition-colors hover:bg-muted/30"
|
|
335
|
+
>
|
|
336
|
+
<td className="px-4 py-3">
|
|
337
|
+
<span className="font-mono text-foreground text-xs">
|
|
338
|
+
{intent.providerIntentId
|
|
339
|
+
? `${intent.providerIntentId.slice(0, 16)}…`
|
|
340
|
+
: `${intent.id.slice(0, 8)}…`}
|
|
341
|
+
</span>
|
|
342
|
+
</td>
|
|
343
|
+
<td className="hidden px-4 py-3 text-foreground text-sm sm:table-cell">
|
|
344
|
+
{intent.email ?? (
|
|
345
|
+
<span className="text-muted-foreground">—</span>
|
|
346
|
+
)}
|
|
347
|
+
</td>
|
|
348
|
+
<td className="px-4 py-3">
|
|
349
|
+
<span
|
|
350
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs ${INTENT_STATUS_COLORS[intent.status] ?? "bg-muted text-muted-foreground"}`}
|
|
351
|
+
>
|
|
352
|
+
{intent.status}
|
|
353
|
+
</span>
|
|
354
|
+
</td>
|
|
355
|
+
<td className="px-4 py-3 text-right font-medium text-foreground text-sm">
|
|
356
|
+
{formatPrice(intent.amount, intent.currency)}
|
|
357
|
+
</td>
|
|
358
|
+
<td className="hidden px-4 py-3 text-right text-muted-foreground text-xs lg:table-cell">
|
|
359
|
+
{timeAgo(intent.createdAt)}
|
|
360
|
+
</td>
|
|
361
|
+
<td className="px-4 py-3 text-right">
|
|
362
|
+
{intent.status === "succeeded" && (
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
onClick={() => setRefundTarget(intent)}
|
|
366
|
+
className="rounded-md px-2 py-1 font-medium text-destructive text-xs hover:bg-destructive/10"
|
|
367
|
+
>
|
|
368
|
+
Refund
|
|
369
|
+
</button>
|
|
370
|
+
)}
|
|
371
|
+
</td>
|
|
372
|
+
</tr>
|
|
373
|
+
))
|
|
374
|
+
)}
|
|
375
|
+
</tbody>
|
|
376
|
+
</table>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{totalPages > 1 && (
|
|
380
|
+
<div className="mt-4 flex items-center justify-center gap-2">
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
384
|
+
disabled={page === 1}
|
|
385
|
+
className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
|
|
386
|
+
>
|
|
387
|
+
Previous
|
|
388
|
+
</button>
|
|
389
|
+
<span className="text-muted-foreground text-sm">
|
|
390
|
+
Page {page} of {totalPages}
|
|
391
|
+
</span>
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
395
|
+
disabled={page === totalPages}
|
|
396
|
+
className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
|
|
397
|
+
>
|
|
398
|
+
Next
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{refundTarget && (
|
|
404
|
+
<RefundModal
|
|
405
|
+
intent={refundTarget}
|
|
406
|
+
onClose={() => setRefundTarget(null)}
|
|
407
|
+
onSuccess={handleRefundSuccess}
|
|
408
|
+
/>
|
|
409
|
+
)}
|
|
410
|
+
</>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function RefundsTab() {
|
|
415
|
+
const api = usePaymentsAdminApi();
|
|
416
|
+
|
|
417
|
+
const { data: intentsData, isLoading: loadingIntents } =
|
|
418
|
+
api.listIntents.useQuery({ limit: "100" }) as {
|
|
419
|
+
data: { intents: PaymentIntent[] } | undefined;
|
|
420
|
+
isLoading: boolean;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const refundedIntents = (intentsData?.intents ?? []).filter(
|
|
424
|
+
(i) => i.status === "refunded",
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
429
|
+
<table className="w-full">
|
|
430
|
+
<thead>
|
|
431
|
+
<tr className="border-border border-b bg-muted/50">
|
|
432
|
+
<th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
433
|
+
Intent ID
|
|
434
|
+
</th>
|
|
435
|
+
<th className="hidden px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide sm:table-cell">
|
|
436
|
+
Customer
|
|
437
|
+
</th>
|
|
438
|
+
<th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
439
|
+
Status
|
|
440
|
+
</th>
|
|
441
|
+
<th className="px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
442
|
+
Amount
|
|
443
|
+
</th>
|
|
444
|
+
<th className="hidden px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide lg:table-cell">
|
|
445
|
+
Date
|
|
446
|
+
</th>
|
|
447
|
+
</tr>
|
|
448
|
+
</thead>
|
|
449
|
+
<tbody className="divide-y divide-border">
|
|
450
|
+
{loadingIntents ? (
|
|
451
|
+
Array.from({ length: 4 }).map((_, i) => (
|
|
452
|
+
<tr key={`skeleton-${i}`}>
|
|
453
|
+
{Array.from({ length: 5 }).map((_, j) => (
|
|
454
|
+
<td key={`skeleton-cell-${j}`} className="px-4 py-3">
|
|
455
|
+
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
456
|
+
</td>
|
|
457
|
+
))}
|
|
458
|
+
</tr>
|
|
459
|
+
))
|
|
460
|
+
) : refundedIntents.length === 0 ? (
|
|
461
|
+
<tr>
|
|
462
|
+
<td colSpan={5} className="px-4 py-12 text-center">
|
|
463
|
+
<p className="font-medium text-foreground text-sm">
|
|
464
|
+
No refunds issued
|
|
465
|
+
</p>
|
|
466
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
467
|
+
Refunds will appear here after being processed
|
|
468
|
+
</p>
|
|
469
|
+
</td>
|
|
470
|
+
</tr>
|
|
471
|
+
) : (
|
|
472
|
+
refundedIntents.map((intent) => (
|
|
473
|
+
<tr
|
|
474
|
+
key={intent.id}
|
|
475
|
+
className="transition-colors hover:bg-muted/30"
|
|
476
|
+
>
|
|
477
|
+
<td className="px-4 py-3">
|
|
478
|
+
<span className="font-mono text-foreground text-xs">
|
|
479
|
+
{intent.providerIntentId
|
|
480
|
+
? `${intent.providerIntentId.slice(0, 16)}…`
|
|
481
|
+
: `${intent.id.slice(0, 8)}…`}
|
|
482
|
+
</span>
|
|
483
|
+
</td>
|
|
484
|
+
<td className="hidden px-4 py-3 text-foreground text-sm sm:table-cell">
|
|
485
|
+
{intent.email ?? (
|
|
486
|
+
<span className="text-muted-foreground">—</span>
|
|
487
|
+
)}
|
|
488
|
+
</td>
|
|
489
|
+
<td className="px-4 py-3">
|
|
490
|
+
<span
|
|
491
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs ${REFUND_STATUS_COLORS.succeeded}`}
|
|
492
|
+
>
|
|
493
|
+
refunded
|
|
494
|
+
</span>
|
|
495
|
+
</td>
|
|
496
|
+
<td className="px-4 py-3 text-right font-medium text-foreground text-sm">
|
|
497
|
+
{formatPrice(intent.amount, intent.currency)}
|
|
498
|
+
</td>
|
|
499
|
+
<td className="hidden px-4 py-3 text-right text-muted-foreground text-xs lg:table-cell">
|
|
500
|
+
{timeAgo(intent.createdAt)}
|
|
501
|
+
</td>
|
|
502
|
+
</tr>
|
|
503
|
+
))
|
|
504
|
+
)}
|
|
505
|
+
</tbody>
|
|
506
|
+
</table>
|
|
507
|
+
</div>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function PaymentsAdmin() {
|
|
512
|
+
const [tab, setTab] = useState<"intents" | "refunds">("intents");
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<PaymentsAdminTemplate
|
|
516
|
+
tab={tab}
|
|
517
|
+
onTabChange={setTab}
|
|
518
|
+
tabContent={tab === "intents" ? <IntentsTab /> : <RefundsTab />}
|
|
519
|
+
/>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createAdminEndpoint, sanitizeText, z } from "@86d-app/core";
|
|
2
|
+
import type { PaymentController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const createRefund = createAdminEndpoint(
|
|
5
|
+
"/admin/payments/:id/refund",
|
|
6
|
+
{
|
|
7
|
+
method: "POST",
|
|
8
|
+
params: z.object({ id: z.string() }),
|
|
9
|
+
body: z.object({
|
|
10
|
+
amount: z.number().int().positive().optional(),
|
|
11
|
+
reason: z.string().max(500).transform(sanitizeText).optional(),
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
async (ctx) => {
|
|
15
|
+
const controller = ctx.context.controllers.payments as PaymentController;
|
|
16
|
+
const intent = await controller.getIntent(ctx.params.id);
|
|
17
|
+
if (!intent) return { error: "Payment intent not found", status: 404 };
|
|
18
|
+
const refund = await controller.createRefund({
|
|
19
|
+
intentId: ctx.params.id,
|
|
20
|
+
amount: ctx.body.amount,
|
|
21
|
+
reason: ctx.body.reason,
|
|
22
|
+
});
|
|
23
|
+
return { refund };
|
|
24
|
+
},
|
|
25
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { PaymentController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const getIntentAdmin = createAdminEndpoint(
|
|
5
|
+
"/admin/payments/:id",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
params: z.object({ id: z.string() }),
|
|
9
|
+
},
|
|
10
|
+
async (ctx) => {
|
|
11
|
+
const controller = ctx.context.controllers.payments as PaymentController;
|
|
12
|
+
const intent = await controller.getIntent(ctx.params.id);
|
|
13
|
+
if (!intent) return { error: "Payment intent not found", status: 404 };
|
|
14
|
+
return { intent };
|
|
15
|
+
},
|
|
16
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createRefund } from "./create-refund";
|
|
2
|
+
import { getIntentAdmin } from "./get-intent";
|
|
3
|
+
import { listIntents } from "./list-intents";
|
|
4
|
+
import { listRefunds } from "./list-refunds";
|
|
5
|
+
|
|
6
|
+
export const adminEndpoints = {
|
|
7
|
+
"/admin/payments": listIntents,
|
|
8
|
+
"/admin/payments/:id": getIntentAdmin,
|
|
9
|
+
"/admin/payments/:id/refund": createRefund,
|
|
10
|
+
"/admin/payments/:id/refunds": listRefunds,
|
|
11
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { PaymentController, PaymentIntentStatus } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listIntents = createAdminEndpoint(
|
|
5
|
+
"/admin/payments",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
customerId: z.string().optional(),
|
|
10
|
+
status: z
|
|
11
|
+
.enum([
|
|
12
|
+
"pending",
|
|
13
|
+
"processing",
|
|
14
|
+
"succeeded",
|
|
15
|
+
"failed",
|
|
16
|
+
"cancelled",
|
|
17
|
+
"refunded",
|
|
18
|
+
])
|
|
19
|
+
.optional(),
|
|
20
|
+
orderId: z.string().optional(),
|
|
21
|
+
take: z.coerce.number().int().min(1).max(100).optional(),
|
|
22
|
+
skip: z.coerce.number().int().min(0).optional(),
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
async (ctx) => {
|
|
26
|
+
const controller = ctx.context.controllers.payments as PaymentController;
|
|
27
|
+
const intents = await controller.listIntents({
|
|
28
|
+
customerId: ctx.query.customerId,
|
|
29
|
+
status: ctx.query.status as PaymentIntentStatus | undefined,
|
|
30
|
+
orderId: ctx.query.orderId,
|
|
31
|
+
take: ctx.query.take ?? 20,
|
|
32
|
+
skip: ctx.query.skip ?? 0,
|
|
33
|
+
});
|
|
34
|
+
return { intents, total: intents.length };
|
|
35
|
+
},
|
|
36
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { PaymentController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listRefunds = createAdminEndpoint(
|
|
5
|
+
"/admin/payments/:id/refunds",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
params: z.object({ id: z.string() }),
|
|
9
|
+
},
|
|
10
|
+
async (ctx) => {
|
|
11
|
+
const controller = ctx.context.controllers.payments as PaymentController;
|
|
12
|
+
const refunds = await controller.listRefunds(ctx.params.id);
|
|
13
|
+
return { refunds };
|
|
14
|
+
},
|
|
15
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Module, ModuleContext } from "@86d-app/core";
|
|
2
|
+
import { adminEndpoints } from "./admin/endpoints";
|
|
3
|
+
import { paymentsSchema } from "./schema";
|
|
4
|
+
import type { PaymentProvider } from "./service";
|
|
5
|
+
import { createPaymentController } from "./service-impl";
|
|
6
|
+
import { storeEndpoints } from "./store/endpoints";
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
PaymentController,
|
|
10
|
+
PaymentIntent,
|
|
11
|
+
PaymentIntentStatus,
|
|
12
|
+
PaymentMethod,
|
|
13
|
+
PaymentProvider,
|
|
14
|
+
ProviderIntentResult,
|
|
15
|
+
ProviderRefundResult,
|
|
16
|
+
Refund,
|
|
17
|
+
RefundStatus,
|
|
18
|
+
} from "./service";
|
|
19
|
+
|
|
20
|
+
export interface PaymentsOptions {
|
|
21
|
+
/** Default currency for payment intents */
|
|
22
|
+
currency?: string;
|
|
23
|
+
/** Payment provider implementation (e.g. StripePaymentProvider) */
|
|
24
|
+
provider?: PaymentProvider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function payments(options?: PaymentsOptions): Module {
|
|
28
|
+
return {
|
|
29
|
+
id: "payments",
|
|
30
|
+
version: "0.0.1",
|
|
31
|
+
schema: paymentsSchema,
|
|
32
|
+
exports: {
|
|
33
|
+
read: ["paymentStatus", "paymentAmount", "paymentMethod"],
|
|
34
|
+
},
|
|
35
|
+
events: {
|
|
36
|
+
emits: ["payment.completed", "payment.failed", "payment.refunded"],
|
|
37
|
+
},
|
|
38
|
+
init: async (ctx: ModuleContext) => {
|
|
39
|
+
const controller = createPaymentController(ctx.data, options?.provider);
|
|
40
|
+
return { controllers: { payments: controller } };
|
|
41
|
+
},
|
|
42
|
+
endpoints: {
|
|
43
|
+
store: storeEndpoints,
|
|
44
|
+
admin: adminEndpoints,
|
|
45
|
+
},
|
|
46
|
+
admin: {
|
|
47
|
+
pages: [
|
|
48
|
+
{
|
|
49
|
+
path: "/admin/payments",
|
|
50
|
+
component: "PaymentsAdmin",
|
|
51
|
+
label: "Payments",
|
|
52
|
+
icon: "CreditCard",
|
|
53
|
+
group: "Sales",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|