@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.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +52 -45
  3. package/README.md +27 -7
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/edge-cases.test.d.ts +2 -0
  7. package/dist/__tests__/edge-cases.test.d.ts.map +1 -0
  8. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  9. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  10. package/dist/__tests__/financial-safety.test.d.ts +2 -0
  11. package/dist/__tests__/financial-safety.test.d.ts.map +1 -0
  12. package/dist/__tests__/service-impl.test.d.ts +2 -0
  13. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  14. package/dist/admin/components/index.d.ts +2 -0
  15. package/dist/admin/components/index.d.ts.map +1 -0
  16. package/dist/admin/components/payments-admin.d.ts +2 -0
  17. package/dist/admin/components/payments-admin.d.ts.map +1 -0
  18. package/dist/admin/endpoints/create-refund.d.ts +20 -0
  19. package/dist/admin/endpoints/create-refund.d.ts.map +1 -0
  20. package/dist/admin/endpoints/get-intent.d.ts +16 -0
  21. package/dist/admin/endpoints/get-intent.d.ts.map +1 -0
  22. package/dist/admin/endpoints/index.d.ts +63 -0
  23. package/dist/admin/endpoints/index.d.ts.map +1 -0
  24. package/dist/admin/endpoints/list-intents.d.ts +22 -0
  25. package/dist/admin/endpoints/list-intents.d.ts.map +1 -0
  26. package/dist/admin/endpoints/list-refunds.d.ts +10 -0
  27. package/dist/admin/endpoints/list-refunds.d.ts.map +1 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/schema.d.ts +169 -0
  31. package/dist/schema.d.ts.map +1 -0
  32. package/dist/service-impl.d.ts +4 -0
  33. package/dist/service-impl.d.ts.map +1 -0
  34. package/dist/service.d.ts +125 -0
  35. package/dist/service.d.ts.map +1 -0
  36. package/dist/store/endpoints/cancel-intent.d.ts +16 -0
  37. package/dist/store/endpoints/cancel-intent.d.ts.map +1 -0
  38. package/dist/store/endpoints/confirm-intent.d.ts +16 -0
  39. package/dist/store/endpoints/confirm-intent.d.ts.map +1 -0
  40. package/dist/store/endpoints/create-intent.d.ts +15 -0
  41. package/dist/store/endpoints/create-intent.d.ts.map +1 -0
  42. package/dist/store/endpoints/delete-method.d.ts +16 -0
  43. package/dist/store/endpoints/delete-method.d.ts.map +1 -0
  44. package/dist/store/endpoints/get-intent.d.ts +16 -0
  45. package/dist/store/endpoints/get-intent.d.ts.map +1 -0
  46. package/dist/store/endpoints/index.d.ts +83 -0
  47. package/dist/store/endpoints/index.d.ts.map +1 -0
  48. package/dist/store/endpoints/list-methods.d.ts +12 -0
  49. package/dist/store/endpoints/list-methods.d.ts.map +1 -0
  50. package/package.json +3 -3
  51. package/src/__tests__/controllers.test.ts +1043 -0
  52. package/src/__tests__/edge-cases.test.ts +547 -0
  53. package/src/__tests__/endpoint-security.test.ts +360 -0
  54. package/src/__tests__/financial-safety.test.ts +576 -0
  55. package/src/__tests__/service-impl.test.ts +30 -5
  56. package/src/admin/endpoints/create-refund.ts +11 -6
  57. package/src/service-impl.ts +60 -0
  58. package/src/store/endpoints/cancel-intent.ts +20 -4
  59. package/src/store/endpoints/confirm-intent.ts +20 -4
  60. package/src/store/endpoints/create-intent.ts +18 -5
  61. package/src/store/endpoints/delete-method.ts +11 -1
  62. package/src/store/endpoints/get-intent.ts +1 -1
  63. package/src/store/endpoints/list-methods.ts +7 -5
@@ -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
- const intent = await controller.cancelIntent(ctx.params.id);
13
- if (!intent) return { error: "Payment intent not found", status: 404 };
14
- return { intent };
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
- const intent = await controller.confirmIntent(ctx.params.id);
13
- if (!intent) return { error: "Payment intent not found", status: 404 };
14
- return { intent };
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.record(z.string(), z.unknown()).optional(),
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: ctx.body.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, z } from "@86d-app/core";
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(ctx.query.customerId);
16
+ const methods = await controller.listPaymentMethods(session.user.id);
15
17
  return { methods };
16
18
  },
17
19
  );