@86d-app/payments 0.0.3 → 0.0.6
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +52 -45
- package/README.md +27 -7
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/edge-cases.test.d.ts +2 -0
- package/dist/__tests__/edge-cases.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/financial-safety.test.d.ts +2 -0
- package/dist/__tests__/financial-safety.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/payments-admin.d.ts +2 -0
- package/dist/admin/components/payments-admin.d.ts.map +1 -0
- package/dist/admin/endpoints/create-refund.d.ts +20 -0
- package/dist/admin/endpoints/create-refund.d.ts.map +1 -0
- package/dist/admin/endpoints/get-intent.d.ts +16 -0
- package/dist/admin/endpoints/get-intent.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +63 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-intents.d.ts +22 -0
- package/dist/admin/endpoints/list-intents.d.ts.map +1 -0
- package/dist/admin/endpoints/list-refunds.d.ts +10 -0
- package/dist/admin/endpoints/list-refunds.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/schema.d.ts +169 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/endpoints/cancel-intent.d.ts +16 -0
- package/dist/store/endpoints/cancel-intent.d.ts.map +1 -0
- package/dist/store/endpoints/confirm-intent.d.ts +16 -0
- package/dist/store/endpoints/confirm-intent.d.ts.map +1 -0
- package/dist/store/endpoints/create-intent.d.ts +15 -0
- package/dist/store/endpoints/create-intent.d.ts.map +1 -0
- package/dist/store/endpoints/delete-method.d.ts +16 -0
- package/dist/store/endpoints/delete-method.d.ts.map +1 -0
- package/dist/store/endpoints/get-intent.d.ts +16 -0
- package/dist/store/endpoints/get-intent.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +83 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-methods.d.ts +12 -0
- package/dist/store/endpoints/list-methods.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1043 -0
- package/src/__tests__/edge-cases.test.ts +547 -0
- package/src/__tests__/endpoint-security.test.ts +360 -0
- package/src/__tests__/financial-safety.test.ts +576 -0
- package/src/__tests__/service-impl.test.ts +30 -5
- package/src/admin/endpoints/create-refund.ts +11 -6
- package/src/service-impl.ts +60 -0
- package/src/store/endpoints/cancel-intent.ts +20 -4
- package/src/store/endpoints/confirm-intent.ts +20 -4
- package/src/store/endpoints/create-intent.ts +18 -5
- package/src/store/endpoints/delete-method.ts +11 -1
- package/src/store/endpoints/get-intent.ts +1 -1
- package/src/store/endpoints/list-methods.ts +7 -5
package/src/service-impl.ts
CHANGED
|
@@ -12,8 +12,23 @@ export function createPaymentController(
|
|
|
12
12
|
data: ModuleDataService,
|
|
13
13
|
provider?: PaymentProvider,
|
|
14
14
|
): PaymentController {
|
|
15
|
+
/** Sum of all succeeded refund amounts for an intent. */
|
|
16
|
+
async function totalRefunded(intentId: string): Promise<number> {
|
|
17
|
+
const refunds = await data.findMany("refund", {
|
|
18
|
+
where: { paymentIntentId: intentId },
|
|
19
|
+
});
|
|
20
|
+
return (refunds as unknown as Refund[]).reduce(
|
|
21
|
+
(sum, r) => sum + (r.status !== "failed" ? r.amount : 0),
|
|
22
|
+
0,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
return {
|
|
16
27
|
async createIntent(params) {
|
|
28
|
+
if (!Number.isInteger(params.amount) || params.amount <= 0) {
|
|
29
|
+
throw new Error("Amount must be a positive integer");
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
const id = crypto.randomUUID();
|
|
18
33
|
const now = new Date();
|
|
19
34
|
|
|
@@ -64,6 +79,15 @@ export function createPaymentController(
|
|
|
64
79
|
const intent = existing as unknown as PaymentIntent;
|
|
65
80
|
if (intent.status === "succeeded") return intent;
|
|
66
81
|
|
|
82
|
+
const terminalStates: PaymentIntentStatus[] = [
|
|
83
|
+
"cancelled",
|
|
84
|
+
"failed",
|
|
85
|
+
"refunded",
|
|
86
|
+
];
|
|
87
|
+
if (terminalStates.includes(intent.status)) {
|
|
88
|
+
throw new Error(`Cannot confirm intent in '${intent.status}' state`);
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
let newStatus: PaymentIntentStatus = "succeeded";
|
|
68
92
|
let newProviderMetadata = intent.providerMetadata;
|
|
69
93
|
|
|
@@ -90,6 +114,15 @@ export function createPaymentController(
|
|
|
90
114
|
const intent = existing as unknown as PaymentIntent;
|
|
91
115
|
if (intent.status === "cancelled") return intent;
|
|
92
116
|
|
|
117
|
+
const nonCancellable: PaymentIntentStatus[] = [
|
|
118
|
+
"succeeded",
|
|
119
|
+
"failed",
|
|
120
|
+
"refunded",
|
|
121
|
+
];
|
|
122
|
+
if (nonCancellable.includes(intent.status)) {
|
|
123
|
+
throw new Error(`Cannot cancel intent in '${intent.status}' state`);
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
let newProviderMetadata = intent.providerMetadata;
|
|
94
127
|
|
|
95
128
|
if (provider && intent.providerIntentId) {
|
|
@@ -192,7 +225,22 @@ export function createPaymentController(
|
|
|
192
225
|
if (!intent) throw new Error("Payment intent not found");
|
|
193
226
|
const pi = intent as unknown as PaymentIntent;
|
|
194
227
|
|
|
228
|
+
const refundableStates: PaymentIntentStatus[] = ["succeeded", "refunded"];
|
|
229
|
+
if (!refundableStates.includes(pi.status)) {
|
|
230
|
+
throw new Error(`Cannot refund intent in '${pi.status}' state`);
|
|
231
|
+
}
|
|
232
|
+
|
|
195
233
|
const refundAmount = params.amount ?? pi.amount;
|
|
234
|
+
if (refundAmount <= 0) {
|
|
235
|
+
throw new Error("Refund amount must be positive");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const alreadyRefunded = await totalRefunded(params.intentId);
|
|
239
|
+
if (alreadyRefunded + refundAmount > pi.amount) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Refund amount ${refundAmount} exceeds remaining refundable amount ${pi.amount - alreadyRefunded}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
196
244
|
|
|
197
245
|
let providerRefundId: string;
|
|
198
246
|
let refundStatus: Refund["status"] = "succeeded";
|
|
@@ -299,6 +347,18 @@ export function createPaymentController(
|
|
|
299
347
|
const now = new Date();
|
|
300
348
|
const refundAmount = params.amount ?? intent.amount;
|
|
301
349
|
|
|
350
|
+
// Deduplicate by providerRefundId — webhook retries must be idempotent
|
|
351
|
+
const existingRefunds = await data.findMany("refund", {
|
|
352
|
+
where: {
|
|
353
|
+
paymentIntentId: intent.id,
|
|
354
|
+
providerRefundId: params.providerRefundId,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
const existing = existingRefunds as unknown as Refund[];
|
|
358
|
+
if (existing.length > 0) {
|
|
359
|
+
return { intent, refund: existing[0] };
|
|
360
|
+
}
|
|
361
|
+
|
|
302
362
|
const refundId = crypto.randomUUID();
|
|
303
363
|
const refund: Refund = {
|
|
304
364
|
id: refundId,
|
|
@@ -5,12 +5,28 @@ export const cancelIntent = createStoreEndpoint(
|
|
|
5
5
|
"/payments/intents/:id/cancel",
|
|
6
6
|
{
|
|
7
7
|
method: "POST",
|
|
8
|
-
params: z.object({ id: z.string() }),
|
|
8
|
+
params: z.object({ id: z.string().max(200) }),
|
|
9
9
|
},
|
|
10
10
|
async (ctx) => {
|
|
11
|
+
const session = ctx.context.session;
|
|
12
|
+
if (!session) {
|
|
13
|
+
return { error: "Authentication required", status: 401 };
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
try {
|
|
18
|
+
const intent = await controller.cancelIntent(ctx.params.id);
|
|
19
|
+
if (!intent) return { error: "Payment intent not found", status: 404 };
|
|
20
|
+
|
|
21
|
+
if (intent.customerId && intent.customerId !== session.user.id) {
|
|
22
|
+
return { error: "Payment intent not found", status: 404 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { intent };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message =
|
|
28
|
+
err instanceof Error ? err.message : "Cannot cancel payment";
|
|
29
|
+
return { error: message, status: 400 };
|
|
30
|
+
}
|
|
15
31
|
},
|
|
16
32
|
);
|
|
@@ -5,12 +5,28 @@ export const confirmIntent = createStoreEndpoint(
|
|
|
5
5
|
"/payments/intents/:id/confirm",
|
|
6
6
|
{
|
|
7
7
|
method: "POST",
|
|
8
|
-
params: z.object({ id: z.string() }),
|
|
8
|
+
params: z.object({ id: z.string().max(200) }),
|
|
9
9
|
},
|
|
10
10
|
async (ctx) => {
|
|
11
|
+
const session = ctx.context.session;
|
|
12
|
+
if (!session) {
|
|
13
|
+
return { error: "Authentication required", status: 401 };
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
try {
|
|
18
|
+
const intent = await controller.confirmIntent(ctx.params.id);
|
|
19
|
+
if (!intent) return { error: "Payment intent not found", status: 404 };
|
|
20
|
+
|
|
21
|
+
if (intent.customerId && intent.customerId !== session.user.id) {
|
|
22
|
+
return { error: "Payment intent not found", status: 404 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { intent };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message =
|
|
28
|
+
err instanceof Error ? err.message : "Cannot confirm payment";
|
|
29
|
+
return { error: message, status: 400 };
|
|
30
|
+
}
|
|
15
31
|
},
|
|
16
32
|
);
|
|
@@ -8,18 +8,31 @@ export const createIntent = createStoreEndpoint(
|
|
|
8
8
|
body: z.object({
|
|
9
9
|
amount: z.number().int().positive(),
|
|
10
10
|
currency: z.string().max(3).optional(),
|
|
11
|
-
email: z.string().email().optional(),
|
|
12
|
-
orderId: z.string().optional(),
|
|
13
|
-
checkoutSessionId: z.string().optional(),
|
|
14
|
-
metadata: z
|
|
11
|
+
email: z.string().email().max(320).optional(),
|
|
12
|
+
orderId: z.string().max(200).optional(),
|
|
13
|
+
checkoutSessionId: z.string().max(200).optional(),
|
|
14
|
+
metadata: z
|
|
15
|
+
.record(
|
|
16
|
+
z
|
|
17
|
+
.string()
|
|
18
|
+
.max(100)
|
|
19
|
+
.regex(/^[\w.-]+$/),
|
|
20
|
+
z.unknown(),
|
|
21
|
+
)
|
|
22
|
+
.refine((obj) => Object.keys(obj).length <= 20, {
|
|
23
|
+
message: "Metadata must have at most 20 keys",
|
|
24
|
+
})
|
|
25
|
+
.optional(),
|
|
15
26
|
}),
|
|
16
27
|
},
|
|
17
28
|
async (ctx) => {
|
|
18
29
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
30
|
+
// Use session email when authenticated to prevent spoofing
|
|
31
|
+
const email = ctx.context.session?.user.email ?? ctx.body.email;
|
|
19
32
|
const intent = await controller.createIntent({
|
|
20
33
|
amount: ctx.body.amount,
|
|
21
34
|
currency: ctx.body.currency,
|
|
22
|
-
email
|
|
35
|
+
email,
|
|
23
36
|
orderId: ctx.body.orderId,
|
|
24
37
|
checkoutSessionId: ctx.body.checkoutSessionId,
|
|
25
38
|
metadata: ctx.body.metadata,
|
|
@@ -5,12 +5,22 @@ export const deletePaymentMethod = createStoreEndpoint(
|
|
|
5
5
|
"/payments/methods/:id",
|
|
6
6
|
{
|
|
7
7
|
method: "DELETE",
|
|
8
|
-
params: z.object({ id: z.string() }),
|
|
8
|
+
params: z.object({ id: z.string().max(200) }),
|
|
9
9
|
},
|
|
10
10
|
async (ctx) => {
|
|
11
|
+
const session = ctx.context.session;
|
|
12
|
+
if (!session) {
|
|
13
|
+
return { error: "Authentication required", status: 401 };
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
12
17
|
const method = await controller.getPaymentMethod(ctx.params.id);
|
|
13
18
|
if (!method) return { error: "Payment method not found", status: 404 };
|
|
19
|
+
|
|
20
|
+
if (method.customerId && method.customerId !== session.user.id) {
|
|
21
|
+
return { error: "Payment method not found", status: 404 };
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
const deleted = await controller.deletePaymentMethod(ctx.params.id);
|
|
15
25
|
return { deleted };
|
|
16
26
|
},
|
|
@@ -5,7 +5,7 @@ export const getIntent = createStoreEndpoint(
|
|
|
5
5
|
"/payments/intents/:id",
|
|
6
6
|
{
|
|
7
7
|
method: "GET",
|
|
8
|
-
params: z.object({ id: z.string() }),
|
|
8
|
+
params: z.object({ id: z.string().max(200) }),
|
|
9
9
|
},
|
|
10
10
|
async (ctx) => {
|
|
11
11
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { createStoreEndpoint
|
|
1
|
+
import { createStoreEndpoint } from "@86d-app/core";
|
|
2
2
|
import type { PaymentController } from "../../service";
|
|
3
3
|
|
|
4
4
|
export const listPaymentMethods = createStoreEndpoint(
|
|
5
5
|
"/payments/methods",
|
|
6
6
|
{
|
|
7
7
|
method: "GET",
|
|
8
|
-
query: z.object({
|
|
9
|
-
customerId: z.string(),
|
|
10
|
-
}),
|
|
11
8
|
},
|
|
12
9
|
async (ctx) => {
|
|
10
|
+
const session = ctx.context.session;
|
|
11
|
+
if (!session) {
|
|
12
|
+
return { error: "Authentication required", status: 401 };
|
|
13
|
+
}
|
|
14
|
+
|
|
13
15
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
14
|
-
const methods = await controller.listPaymentMethods(
|
|
16
|
+
const methods = await controller.listPaymentMethods(session.user.id);
|
|
15
17
|
return { methods };
|
|
16
18
|
},
|
|
17
19
|
);
|