@pulgueta/epayco-convex 0.1.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.
Files changed (169) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +945 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +222 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +355 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +78 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +580 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/banks.d.ts +14 -0
  28. package/dist/component/banks.d.ts.map +1 -0
  29. package/dist/component/banks.js +37 -0
  30. package/dist/component/banks.js.map +1 -0
  31. package/dist/component/cashApi.d.ts +59 -0
  32. package/dist/component/cashApi.d.ts.map +1 -0
  33. package/dist/component/cashApi.js +88 -0
  34. package/dist/component/cashApi.js.map +1 -0
  35. package/dist/component/chargesApi.d.ts +64 -0
  36. package/dist/component/chargesApi.d.ts.map +1 -0
  37. package/dist/component/chargesApi.js +106 -0
  38. package/dist/component/chargesApi.js.map +1 -0
  39. package/dist/component/convex.config.d.ts +3 -0
  40. package/dist/component/convex.config.d.ts.map +1 -0
  41. package/dist/component/convex.config.js +6 -0
  42. package/dist/component/convex.config.js.map +1 -0
  43. package/dist/component/customers.d.ts +67 -0
  44. package/dist/component/customers.d.ts.map +1 -0
  45. package/dist/component/customers.js +103 -0
  46. package/dist/component/customers.js.map +1 -0
  47. package/dist/component/customersApi.d.ts +99 -0
  48. package/dist/component/customersApi.d.ts.map +1 -0
  49. package/dist/component/customersApi.js +176 -0
  50. package/dist/component/customersApi.js.map +1 -0
  51. package/dist/component/daviplataApi.d.ts +43 -0
  52. package/dist/component/daviplataApi.d.ts.map +1 -0
  53. package/dist/component/daviplataApi.js +103 -0
  54. package/dist/component/daviplataApi.js.map +1 -0
  55. package/dist/component/epaycoClient.d.ts +84 -0
  56. package/dist/component/epaycoClient.d.ts.map +1 -0
  57. package/dist/component/epaycoClient.js +422 -0
  58. package/dist/component/epaycoClient.js.map +1 -0
  59. package/dist/component/payloads.d.ts +34 -0
  60. package/dist/component/payloads.d.ts.map +1 -0
  61. package/dist/component/payloads.js +45 -0
  62. package/dist/component/payloads.js.map +1 -0
  63. package/dist/component/plans.d.ts +47 -0
  64. package/dist/component/plans.d.ts.map +1 -0
  65. package/dist/component/plans.js +83 -0
  66. package/dist/component/plans.js.map +1 -0
  67. package/dist/component/plansApi.d.ts +64 -0
  68. package/dist/component/plansApi.d.ts.map +1 -0
  69. package/dist/component/plansApi.js +121 -0
  70. package/dist/component/plansApi.js.map +1 -0
  71. package/dist/component/pseApi.d.ts +68 -0
  72. package/dist/component/pseApi.d.ts.map +1 -0
  73. package/dist/component/pseApi.js +113 -0
  74. package/dist/component/pseApi.js.map +1 -0
  75. package/dist/component/rateLimits.d.ts +69 -0
  76. package/dist/component/rateLimits.d.ts.map +1 -0
  77. package/dist/component/rateLimits.js +67 -0
  78. package/dist/component/rateLimits.js.map +1 -0
  79. package/dist/component/safetypayApi.d.ts +35 -0
  80. package/dist/component/safetypayApi.d.ts.map +1 -0
  81. package/dist/component/safetypayApi.js +68 -0
  82. package/dist/component/safetypayApi.js.map +1 -0
  83. package/dist/component/schema.d.ts +200 -0
  84. package/dist/component/schema.d.ts.map +1 -0
  85. package/dist/component/schema.js +104 -0
  86. package/dist/component/schema.js.map +1 -0
  87. package/dist/component/signature.d.ts +11 -0
  88. package/dist/component/signature.d.ts.map +1 -0
  89. package/dist/component/signature.js +28 -0
  90. package/dist/component/signature.js.map +1 -0
  91. package/dist/component/status.d.ts +12 -0
  92. package/dist/component/status.d.ts.map +1 -0
  93. package/dist/component/status.js +55 -0
  94. package/dist/component/status.js.map +1 -0
  95. package/dist/component/subscriptions.d.ts +69 -0
  96. package/dist/component/subscriptions.d.ts.map +1 -0
  97. package/dist/component/subscriptions.js +114 -0
  98. package/dist/component/subscriptions.js.map +1 -0
  99. package/dist/component/subscriptionsApi.d.ts +62 -0
  100. package/dist/component/subscriptionsApi.d.ts.map +1 -0
  101. package/dist/component/subscriptionsApi.js +147 -0
  102. package/dist/component/subscriptionsApi.js.map +1 -0
  103. package/dist/component/tokens.d.ts +31 -0
  104. package/dist/component/tokens.d.ts.map +1 -0
  105. package/dist/component/tokens.js +79 -0
  106. package/dist/component/tokens.js.map +1 -0
  107. package/dist/component/tokensApi.d.ts +18 -0
  108. package/dist/component/tokensApi.d.ts.map +1 -0
  109. package/dist/component/tokensApi.js +53 -0
  110. package/dist/component/tokensApi.js.map +1 -0
  111. package/dist/component/transactions.d.ts +103 -0
  112. package/dist/component/transactions.d.ts.map +1 -0
  113. package/dist/component/transactions.js +177 -0
  114. package/dist/component/transactions.js.map +1 -0
  115. package/dist/component/validators.d.ts +571 -0
  116. package/dist/component/validators.d.ts.map +1 -0
  117. package/dist/component/validators.js +203 -0
  118. package/dist/component/validators.js.map +1 -0
  119. package/dist/component/webhooks.d.ts +55 -0
  120. package/dist/component/webhooks.d.ts.map +1 -0
  121. package/dist/component/webhooks.js +172 -0
  122. package/dist/component/webhooks.js.map +1 -0
  123. package/dist/react/index.d.ts +16 -0
  124. package/dist/react/index.d.ts.map +1 -0
  125. package/dist/react/index.js +43 -0
  126. package/dist/react/index.js.map +1 -0
  127. package/package.json +106 -0
  128. package/src/client/_generated/_ignore.ts +1 -0
  129. package/src/client/index.test.ts +66 -0
  130. package/src/client/index.ts +633 -0
  131. package/src/client/setup.test.ts +26 -0
  132. package/src/component/_generated/api.ts +94 -0
  133. package/src/component/_generated/component.ts +809 -0
  134. package/src/component/_generated/dataModel.ts +60 -0
  135. package/src/component/_generated/server.ts +156 -0
  136. package/src/component/banks.ts +41 -0
  137. package/src/component/cashApi.ts +100 -0
  138. package/src/component/chargesApi.ts +119 -0
  139. package/src/component/convex.config.ts +7 -0
  140. package/src/component/customers.test.ts +122 -0
  141. package/src/component/customers.ts +116 -0
  142. package/src/component/customersApi.ts +206 -0
  143. package/src/component/daviplataApi.ts +119 -0
  144. package/src/component/epaycoApi.test.ts +110 -0
  145. package/src/component/epaycoClient.ts +578 -0
  146. package/src/component/payloads.ts +67 -0
  147. package/src/component/plans.test.ts +129 -0
  148. package/src/component/plans.ts +86 -0
  149. package/src/component/plansApi.ts +135 -0
  150. package/src/component/pseApi.ts +125 -0
  151. package/src/component/rateLimits.ts +67 -0
  152. package/src/component/safetypayApi.ts +78 -0
  153. package/src/component/schema.ts +124 -0
  154. package/src/component/setup.test.helper.ts +10 -0
  155. package/src/component/setup.test.ts +22 -0
  156. package/src/component/signature.ts +38 -0
  157. package/src/component/status.ts +71 -0
  158. package/src/component/subscriptions.test.ts +117 -0
  159. package/src/component/subscriptions.ts +128 -0
  160. package/src/component/subscriptionsApi.ts +172 -0
  161. package/src/component/tokens.ts +89 -0
  162. package/src/component/tokensApi.ts +63 -0
  163. package/src/component/transactions.test.ts +227 -0
  164. package/src/component/transactions.ts +200 -0
  165. package/src/component/validators.ts +245 -0
  166. package/src/component/webhooks.test.ts +137 -0
  167. package/src/component/webhooks.ts +229 -0
  168. package/src/react/index.ts +71 -0
  169. package/src/test.ts +13 -0
@@ -0,0 +1,124 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ customers: defineTable({
6
+ userId: v.string(),
7
+ epaycoCustomerId: v.string(),
8
+ name: v.string(),
9
+ email: v.string(),
10
+ phone: v.optional(v.string()),
11
+ docType: v.optional(v.string()),
12
+ docNumber: v.optional(v.string()),
13
+ defaultCard: v.optional(v.string()),
14
+ lastSyncedAt: v.number(),
15
+ })
16
+ .index("by_userId", ["userId"])
17
+ .index("by_epaycoCustomerId", ["epaycoCustomerId"])
18
+ .index("by_email", ["email"]),
19
+
20
+ tokens: defineTable({
21
+ userId: v.string(),
22
+ epaycoTokenId: v.string(),
23
+ epaycoCustomerId: v.string(),
24
+ mask: v.string(),
25
+ franchise: v.string(),
26
+ isActive: v.boolean(),
27
+ lastSyncedAt: v.number(),
28
+ })
29
+ .index("by_userId", ["userId"])
30
+ .index("by_epaycoTokenId", ["epaycoTokenId"]),
31
+
32
+ transactions: defineTable({
33
+ userId: v.string(),
34
+ epaycoRef: v.string(),
35
+ epaycoTransactionId: v.optional(v.string()),
36
+ paymentMethod: v.union(
37
+ v.literal("credit_card"),
38
+ v.literal("pse"),
39
+ v.literal("cash"),
40
+ v.literal("daviplata"),
41
+ v.literal("safetypay"),
42
+ ),
43
+ status: v.string(),
44
+ amount: v.number(),
45
+ currency: v.string(),
46
+ description: v.string(),
47
+ customerEmail: v.optional(v.string()),
48
+ bankName: v.optional(v.string()),
49
+ cashProvider: v.optional(v.string()),
50
+ responseCode: v.optional(v.string()),
51
+ responseMessage: v.optional(v.string()),
52
+ franchise: v.optional(v.string()),
53
+ splitPayment: v.optional(v.boolean()),
54
+ splitReceivers: v.optional(
55
+ v.array(
56
+ v.object({
57
+ id: v.string(),
58
+ total: v.number(),
59
+ iva: v.number(),
60
+ base_iva: v.number(),
61
+ fee: v.optional(v.number()),
62
+ }),
63
+ ),
64
+ ),
65
+ rawResponse: v.optional(v.any()),
66
+ lastSyncedAt: v.number(),
67
+ })
68
+ .index("by_userId", ["userId"])
69
+ .index("by_epaycoRef", ["epaycoRef"])
70
+ .index("by_userId_and_status", ["userId", "status"]),
71
+
72
+ plans: defineTable({
73
+ epaycoPlanId: v.string(),
74
+ name: v.string(),
75
+ description: v.string(),
76
+ amount: v.number(),
77
+ currency: v.string(),
78
+ interval: v.string(),
79
+ intervalCount: v.number(),
80
+ trialDays: v.number(),
81
+ status: v.string(),
82
+ lastSyncedAt: v.number(),
83
+ })
84
+ .index("by_epaycoPlanId", ["epaycoPlanId"])
85
+ .index("by_status", ["status"]),
86
+
87
+ subscriptions: defineTable({
88
+ userId: v.string(),
89
+ epaycoSubscriptionId: v.string(),
90
+ epaycoCustomerId: v.string(),
91
+ epaycoPlanId: v.string(),
92
+ epaycoTokenId: v.optional(v.string()),
93
+ status: v.string(),
94
+ currentPeriodStart: v.optional(v.number()),
95
+ currentPeriodEnd: v.optional(v.number()),
96
+ lastSyncedAt: v.number(),
97
+ })
98
+ .index("by_userId", ["userId"])
99
+ .index("by_epaycoSubscriptionId", ["epaycoSubscriptionId"])
100
+ .index("by_customerId_and_status", ["epaycoCustomerId", "status"]),
101
+
102
+ banks: defineTable({
103
+ bankCode: v.string(),
104
+ bankName: v.string(),
105
+ lastSyncedAt: v.number(),
106
+ }).index("by_bankCode", ["bankCode"]),
107
+
108
+ webhookEvents: defineTable({
109
+ epaycoRef: v.string(),
110
+ epaycoTransactionId: v.optional(v.string()),
111
+ eventType: v.string(),
112
+ status: v.union(
113
+ v.literal("pending"),
114
+ v.literal("processed"),
115
+ v.literal("failed"),
116
+ ),
117
+ rawPayload: v.any(),
118
+ errorMessage: v.optional(v.string()),
119
+ processedAt: v.optional(v.number()),
120
+ lastSyncedAt: v.number(),
121
+ })
122
+ .index("by_epaycoRef", ["epaycoRef"])
123
+ .index("by_status", ["status"]),
124
+ });
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+ import { convexTest } from "convex-test";
3
+ import schema from "./schema.js";
4
+
5
+ const modules = import.meta.glob("./**/*.ts");
6
+
7
+ export function initConvexTest() {
8
+ const t = convexTest(schema, modules);
9
+ return t;
10
+ }
@@ -0,0 +1,22 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.helper.js";
6
+
7
+ export { initConvexTest };
8
+
9
+ describe("component setup", () => {
10
+ beforeEach(async () => {
11
+ vi.useFakeTimers();
12
+ });
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+ test("schema tables exist", async () => {
17
+ const t = initConvexTest();
18
+ // Verify we can query the customers table
19
+ const customers = await t.query(api.customers.listLocalCustomers, {});
20
+ expect(customers).toEqual([]);
21
+ });
22
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * ePayco webhook (confirmation) signature verification.
3
+ *
4
+ * ePayco signs each confirmation with:
5
+ * SHA256( p_cust_id_cliente ^ p_key ^ x_ref_payco ^ x_transaction_id ^ x_amount ^ x_currency_code )
6
+ *
7
+ * This runs in the default Convex (V8) runtime using Web Crypto — no Node
8
+ * built-ins required — so it is safe to import from queries/mutations/actions.
9
+ */
10
+ export async function verifyWebhookSignature(
11
+ custIdCliente: string,
12
+ pKey: string,
13
+ xRefPayco: string,
14
+ xTransactionId: string,
15
+ xAmount: string,
16
+ xCurrencyCode: string,
17
+ receivedSignature: string,
18
+ ): Promise<boolean> {
19
+ const data = `${custIdCliente}^${pKey}^${xRefPayco}^${xTransactionId}^${xAmount}^${xCurrencyCode}`;
20
+ const hashBuffer = await crypto.subtle.digest(
21
+ "SHA-256",
22
+ new TextEncoder().encode(data),
23
+ );
24
+ const computedSignature = Array.from(new Uint8Array(hashBuffer))
25
+ .map((b) => b.toString(16).padStart(2, "0"))
26
+ .join("");
27
+ return timingSafeEqual(computedSignature, receivedSignature);
28
+ }
29
+
30
+ /** Constant-time string comparison to avoid leaking timing information. */
31
+ function timingSafeEqual(a: string, b: string): boolean {
32
+ if (a.length !== b.length) return false;
33
+ let mismatch = 0;
34
+ for (let i = 0; i < a.length; i++) {
35
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
36
+ }
37
+ return mismatch === 0;
38
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Normalization of ePayco transaction states into the component's canonical
3
+ * status vocabulary: pending | approved | rejected | failed | expired | reversed.
4
+ *
5
+ * Pure functions, safe in any runtime.
6
+ */
7
+ export type TransactionStatus =
8
+ | "pending"
9
+ | "approved"
10
+ | "rejected"
11
+ | "failed"
12
+ | "expired"
13
+ | "reversed";
14
+
15
+ /** Map ePayco's `x_cod_response` (webhook) numeric code to a canonical status. */
16
+ export function statusFromCodResponse(cod: string | number): TransactionStatus {
17
+ switch (String(cod)) {
18
+ case "1":
19
+ return "approved";
20
+ case "2":
21
+ case "11":
22
+ case "12":
23
+ return "rejected";
24
+ case "3":
25
+ case "7":
26
+ return "pending";
27
+ case "4":
28
+ return "failed";
29
+ case "6":
30
+ return "reversed";
31
+ case "9":
32
+ // 9 = Expirada (expired).
33
+ return "expired";
34
+ case "10":
35
+ // 10 = Abandonada — aligned with statusFromEstado("abandonada") => failed.
36
+ return "failed";
37
+ default:
38
+ return "pending";
39
+ }
40
+ }
41
+
42
+ /** Map ePayco's textual `estado` / `x_response` to a canonical status. */
43
+ export function statusFromEstado(
44
+ estado: string | undefined | null,
45
+ ): TransactionStatus {
46
+ switch ((estado ?? "").toLowerCase().trim()) {
47
+ case "aceptada":
48
+ case "approved":
49
+ return "approved";
50
+ case "rechazada":
51
+ case "rejected":
52
+ return "rejected";
53
+ case "pendiente":
54
+ case "pending":
55
+ return "pending";
56
+ case "fallida":
57
+ case "abandonada":
58
+ case "failed":
59
+ return "failed";
60
+ case "cancelada":
61
+ return "rejected";
62
+ case "reversada":
63
+ case "reversed":
64
+ return "reversed";
65
+ case "expirada":
66
+ case "expired":
67
+ return "expired";
68
+ default:
69
+ return "pending";
70
+ }
71
+ }
@@ -0,0 +1,117 @@
1
+ /// <reference types="vite/client" />
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { initConvexTest } from "./setup.test.helper.js";
4
+ import { api, internal } from "./_generated/api.js";
5
+
6
+ describe("subscriptions", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ test("upsertSubscription creates a new subscription", async () => {
15
+ const t = initConvexTest();
16
+ const id = await t.mutation(internal.subscriptions.upsertSubscription, {
17
+ userId: "user1",
18
+ epaycoSubscriptionId: "sub_001",
19
+ epaycoCustomerId: "cust_001",
20
+ epaycoPlanId: "plan_basic",
21
+ status: "active",
22
+ lastSyncedAt: 1000,
23
+ });
24
+ expect(id).toBeDefined();
25
+
26
+ const sub = await t.query(api.subscriptions.getLocalSubscription, {
27
+ epaycoSubscriptionId: "sub_001",
28
+ });
29
+ expect(sub).not.toBeNull();
30
+ expect(sub!.status).toBe("active");
31
+ expect(sub!.userId).toBe("user1");
32
+ });
33
+
34
+ test("upsertSubscription is idempotent", async () => {
35
+ const t = initConvexTest();
36
+ await t.mutation(internal.subscriptions.upsertSubscription, {
37
+ userId: "user1",
38
+ epaycoSubscriptionId: "sub_002",
39
+ epaycoCustomerId: "cust_001",
40
+ epaycoPlanId: "plan_basic",
41
+ status: "active",
42
+ lastSyncedAt: 1000,
43
+ });
44
+
45
+ await t.mutation(internal.subscriptions.upsertSubscription, {
46
+ epaycoSubscriptionId: "sub_002",
47
+ status: "cancelled",
48
+ lastSyncedAt: 1000,
49
+ });
50
+
51
+ const sub = await t.query(api.subscriptions.getLocalSubscription, {
52
+ epaycoSubscriptionId: "sub_002",
53
+ });
54
+ expect(sub!.status).toBe("active");
55
+ });
56
+
57
+ test("getActiveSubscription returns active sub", async () => {
58
+ const t = initConvexTest();
59
+ await t.mutation(internal.subscriptions.upsertSubscription, {
60
+ userId: "user1",
61
+ epaycoSubscriptionId: "sub_010",
62
+ epaycoCustomerId: "cust_001",
63
+ epaycoPlanId: "plan_basic",
64
+ status: "active",
65
+ lastSyncedAt: 1000,
66
+ });
67
+
68
+ const active = await t.query(api.subscriptions.getActiveSubscription, {
69
+ userId: "user1",
70
+ });
71
+ expect(active).not.toBeNull();
72
+ expect(active!.epaycoSubscriptionId).toBe("sub_010");
73
+ });
74
+
75
+ test("getActiveSubscription returns null for cancelled", async () => {
76
+ const t = initConvexTest();
77
+ await t.mutation(internal.subscriptions.upsertSubscription, {
78
+ userId: "user1",
79
+ epaycoSubscriptionId: "sub_020",
80
+ epaycoCustomerId: "cust_001",
81
+ epaycoPlanId: "plan_basic",
82
+ status: "cancelled",
83
+ lastSyncedAt: 1000,
84
+ });
85
+
86
+ const active = await t.query(api.subscriptions.getActiveSubscription, {
87
+ userId: "user1",
88
+ });
89
+ expect(active).toBeNull();
90
+ });
91
+
92
+ test("listLocalSubscriptionsByUser excludes cancelled", async () => {
93
+ const t = initConvexTest();
94
+ await t.mutation(internal.subscriptions.upsertSubscription, {
95
+ userId: "user1",
96
+ epaycoSubscriptionId: "sub_030",
97
+ epaycoCustomerId: "cust_001",
98
+ epaycoPlanId: "plan_basic",
99
+ status: "active",
100
+ lastSyncedAt: 1000,
101
+ });
102
+ await t.mutation(internal.subscriptions.upsertSubscription, {
103
+ userId: "user1",
104
+ epaycoSubscriptionId: "sub_031",
105
+ epaycoCustomerId: "cust_001",
106
+ epaycoPlanId: "plan_pro",
107
+ status: "cancelled",
108
+ lastSyncedAt: 1000,
109
+ });
110
+
111
+ const subs = await t.query(api.subscriptions.listLocalSubscriptionsByUser, {
112
+ userId: "user1",
113
+ });
114
+ expect(subs).toHaveLength(1);
115
+ expect(subs[0].epaycoSubscriptionId).toBe("sub_030");
116
+ });
117
+ });
@@ -0,0 +1,128 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, query } from "./_generated/server.js";
3
+
4
+ /** Local persistence for subscriptions. */
5
+
6
+ export const getLocalSubscription = query({
7
+ args: { epaycoSubscriptionId: v.string() },
8
+ returns: v.any(),
9
+ handler: async (ctx, args) => {
10
+ return await ctx.db
11
+ .query("subscriptions")
12
+ .withIndex("by_epaycoSubscriptionId", (q) =>
13
+ q.eq("epaycoSubscriptionId", args.epaycoSubscriptionId),
14
+ )
15
+ .first();
16
+ },
17
+ });
18
+
19
+ export const listLocalSubscriptionsByUser = query({
20
+ args: { userId: v.string() },
21
+ returns: v.any(),
22
+ handler: async (ctx, args) => {
23
+ const subs = await ctx.db
24
+ .query("subscriptions")
25
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
26
+ .order("desc")
27
+ .take(100);
28
+ return subs.filter((s) => s.status !== "cancelled");
29
+ },
30
+ });
31
+
32
+ export const getActiveSubscription = query({
33
+ args: { userId: v.string() },
34
+ returns: v.any(),
35
+ handler: async (ctx, args) => {
36
+ const subs = await ctx.db
37
+ .query("subscriptions")
38
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
39
+ .order("desc")
40
+ .take(100);
41
+ return subs.find((s) => s.status === "active") ?? null;
42
+ },
43
+ });
44
+
45
+ /**
46
+ * Patch the status of an existing subscription only. Unlike `upsertSubscription`
47
+ * this never inserts, so cancelling a subscription that was never persisted
48
+ * locally (e.g. created out-of-band) can't create an orphan row with empty
49
+ * identity fields. Returns whether a row was found and updated.
50
+ */
51
+ export const updateSubscriptionStatus = internalMutation({
52
+ args: {
53
+ epaycoSubscriptionId: v.string(),
54
+ status: v.string(),
55
+ lastSyncedAt: v.number(),
56
+ },
57
+ returns: v.boolean(),
58
+ handler: async (ctx, args) => {
59
+ const existing = await ctx.db
60
+ .query("subscriptions")
61
+ .withIndex("by_epaycoSubscriptionId", (q) =>
62
+ q.eq("epaycoSubscriptionId", args.epaycoSubscriptionId),
63
+ )
64
+ .first();
65
+
66
+ if (!existing) return false;
67
+ if (existing.lastSyncedAt >= args.lastSyncedAt) return true;
68
+
69
+ await ctx.db.patch(existing._id, {
70
+ status: args.status,
71
+ lastSyncedAt: args.lastSyncedAt,
72
+ });
73
+ return true;
74
+ },
75
+ });
76
+
77
+ export const upsertSubscription = internalMutation({
78
+ args: {
79
+ userId: v.optional(v.string()),
80
+ epaycoSubscriptionId: v.string(),
81
+ epaycoCustomerId: v.optional(v.string()),
82
+ epaycoPlanId: v.optional(v.string()),
83
+ epaycoTokenId: v.optional(v.string()),
84
+ status: v.string(),
85
+ currentPeriodStart: v.optional(v.number()),
86
+ currentPeriodEnd: v.optional(v.number()),
87
+ lastSyncedAt: v.number(),
88
+ },
89
+ returns: v.id("subscriptions"),
90
+ handler: async (ctx, args) => {
91
+ const existing = await ctx.db
92
+ .query("subscriptions")
93
+ .withIndex("by_epaycoSubscriptionId", (q) =>
94
+ q.eq("epaycoSubscriptionId", args.epaycoSubscriptionId),
95
+ )
96
+ .first();
97
+
98
+ if (existing) {
99
+ if (existing.lastSyncedAt >= args.lastSyncedAt) return existing._id;
100
+
101
+ const patch: Record<string, unknown> = {
102
+ status: args.status,
103
+ lastSyncedAt: args.lastSyncedAt,
104
+ };
105
+ if (args.currentPeriodStart !== undefined)
106
+ patch.currentPeriodStart = args.currentPeriodStart;
107
+ if (args.currentPeriodEnd !== undefined)
108
+ patch.currentPeriodEnd = args.currentPeriodEnd;
109
+ if (args.epaycoTokenId !== undefined)
110
+ patch.epaycoTokenId = args.epaycoTokenId;
111
+
112
+ await ctx.db.patch(existing._id, patch);
113
+ return existing._id;
114
+ }
115
+
116
+ return await ctx.db.insert("subscriptions", {
117
+ userId: args.userId ?? "",
118
+ epaycoSubscriptionId: args.epaycoSubscriptionId,
119
+ epaycoCustomerId: args.epaycoCustomerId ?? "",
120
+ epaycoPlanId: args.epaycoPlanId ?? "",
121
+ epaycoTokenId: args.epaycoTokenId,
122
+ status: args.status,
123
+ currentPeriodStart: args.currentPeriodStart,
124
+ currentPeriodEnd: args.currentPeriodEnd,
125
+ lastSyncedAt: args.lastSyncedAt,
126
+ });
127
+ },
128
+ });
@@ -0,0 +1,172 @@
1
+ import { v } from "convex/values";
2
+ import { action } from "./_generated/server.js";
3
+ import { internal } from "./_generated/api.js";
4
+ import {
5
+ epaycoCredentialsValidator,
6
+ subscriptionInfoValidator,
7
+ } from "./validators.js";
8
+ import { getEpaycoClient, unwrap, dataOf, pick } from "./epaycoClient.js";
9
+ import { statusFromEstado } from "./status.js";
10
+ import { rateLimiter } from "./rateLimits.js";
11
+
12
+ /** Create a subscription (enroll a customer in a plan). */
13
+ export const createSubscription = action({
14
+ args: {
15
+ credentials: epaycoCredentialsValidator,
16
+ userId: v.string(),
17
+ subscriptionInfo: subscriptionInfoValidator,
18
+ },
19
+ returns: v.any(),
20
+ handler: async (ctx, args) => {
21
+ await rateLimiter.limit(ctx, "createSubscription", {
22
+ key: args.userId,
23
+ throws: true,
24
+ });
25
+
26
+ const s = args.subscriptionInfo;
27
+ const epayco = getEpaycoClient(args.credentials);
28
+ const result = unwrap(
29
+ await epayco.subscriptions.create({
30
+ id_plan: s.idPlan,
31
+ customer: s.customer,
32
+ token_card: s.tokenCard,
33
+ doc_type: s.docType,
34
+ doc_number: s.docNumber,
35
+ ...(s.urlConfirmation ? { url_confirmation: s.urlConfirmation } : {}),
36
+ ...(s.methodConfirmation
37
+ ? { method_confirmation: s.methodConfirmation }
38
+ : {}),
39
+ }),
40
+ );
41
+
42
+ const data = dataOf(result);
43
+ const subscriptionId =
44
+ pick(data, ["id", "id_subscription", "subscription"]) ??
45
+ pick(result, ["id", "id_subscription"]);
46
+
47
+ if (subscriptionId) {
48
+ await ctx.runMutation(internal.subscriptions.upsertSubscription, {
49
+ userId: args.userId,
50
+ epaycoSubscriptionId: subscriptionId,
51
+ epaycoCustomerId: s.customer,
52
+ epaycoPlanId: s.idPlan,
53
+ epaycoTokenId: s.tokenCard,
54
+ status: pick(data, ["status"]) ?? "active",
55
+ lastSyncedAt: Date.now(),
56
+ });
57
+ }
58
+
59
+ return result;
60
+ },
61
+ });
62
+
63
+ export const getSubscription = action({
64
+ args: {
65
+ credentials: epaycoCredentialsValidator,
66
+ epaycoSubscriptionId: v.string(),
67
+ },
68
+ returns: v.any(),
69
+ handler: async (_ctx, args) => {
70
+ const epayco = getEpaycoClient(args.credentials);
71
+ return unwrap(await epayco.subscriptions.get(args.epaycoSubscriptionId));
72
+ },
73
+ });
74
+
75
+ export const listSubscriptions = action({
76
+ args: { credentials: epaycoCredentialsValidator },
77
+ returns: v.any(),
78
+ handler: async (_ctx, args) => {
79
+ const epayco = getEpaycoClient(args.credentials);
80
+ return unwrap(await epayco.subscriptions.list());
81
+ },
82
+ });
83
+
84
+ export const cancelSubscription = action({
85
+ args: {
86
+ credentials: epaycoCredentialsValidator,
87
+ epaycoSubscriptionId: v.string(),
88
+ },
89
+ returns: v.any(),
90
+ handler: async (ctx, args) => {
91
+ const epayco = getEpaycoClient(args.credentials);
92
+ const result = unwrap(
93
+ await epayco.subscriptions.cancel(args.epaycoSubscriptionId),
94
+ );
95
+
96
+ // Update-only: never insert an orphan row for a subscription we don't track.
97
+ await ctx.runMutation(internal.subscriptions.updateSubscriptionStatus, {
98
+ epaycoSubscriptionId: args.epaycoSubscriptionId,
99
+ status: "cancelled",
100
+ lastSyncedAt: Date.now(),
101
+ });
102
+
103
+ return result;
104
+ },
105
+ });
106
+
107
+ /** Immediately charge a subscription's plan (one-off recurring payment). */
108
+ export const chargeSubscription = action({
109
+ args: {
110
+ credentials: epaycoCredentialsValidator,
111
+ userId: v.string(),
112
+ idPlan: v.string(),
113
+ customer: v.string(),
114
+ tokenCard: v.string(),
115
+ docType: v.string(),
116
+ docNumber: v.string(),
117
+ ip: v.optional(v.string()),
118
+ },
119
+ returns: v.any(),
120
+ handler: async (ctx, args) => {
121
+ await rateLimiter.limit(ctx, "chargeSubscription", {
122
+ key: args.userId,
123
+ throws: true,
124
+ });
125
+
126
+ const epayco = getEpaycoClient(args.credentials);
127
+ const result = unwrap(
128
+ await epayco.subscriptions.charge({
129
+ id_plan: args.idPlan,
130
+ customer: args.customer,
131
+ token_card: args.tokenCard,
132
+ doc_type: args.docType,
133
+ doc_number: args.docNumber,
134
+ ...(args.ip ? { ip: args.ip } : {}),
135
+ }),
136
+ );
137
+
138
+ // Record the resulting charge so recurring payments appear in local history.
139
+ // Subscription charges settle on a card, so they're stored as credit_card.
140
+ const data = dataOf(result);
141
+ const refPayco = pick(data, ["ref_payco", "refPayco"]);
142
+ if (refPayco) {
143
+ await ctx.runMutation(internal.transactions.upsertTransaction, {
144
+ userId: args.userId,
145
+ epaycoRef: refPayco,
146
+ epaycoTransactionId: pick(data, [
147
+ "transactionId",
148
+ "transaction_id",
149
+ "x_transaction_id",
150
+ ]),
151
+ paymentMethod: "credit_card",
152
+ status: statusFromEstado(
153
+ pick(data, ["estado", "x_response", "respuesta"]),
154
+ ),
155
+ amount: Number(pick(data, ["valor", "value", "x_amount", "amount"]) ?? 0),
156
+ currency: pick(data, ["moneda", "currency", "x_currency_code"]) ?? "COP",
157
+ description: `Subscription charge for plan ${args.idPlan}`,
158
+ franchise: pick(data, ["franchise", "franquicia", "x_franchise"]),
159
+ responseCode: pick(data, ["cod_respuesta", "x_cod_response"]),
160
+ responseMessage: pick(data, [
161
+ "respuesta",
162
+ "x_response_reason_text",
163
+ "response_reason_text",
164
+ ]),
165
+ rawResponse: data,
166
+ lastSyncedAt: Date.now(),
167
+ });
168
+ }
169
+
170
+ return result;
171
+ },
172
+ });