@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,89 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, query } from "./_generated/server.js";
3
+
4
+ /**
5
+ * Local persistence for tokenized cards. Card data itself is never stored —
6
+ * only the ePayco token id, masked PAN, and franchise.
7
+ */
8
+
9
+ export const getLocalTokens = query({
10
+ args: { userId: v.string() },
11
+ returns: v.any(),
12
+ handler: async (ctx, args) => {
13
+ const tokens = await ctx.db
14
+ .query("tokens")
15
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
16
+ .take(100);
17
+ return tokens.filter((t) => t.isActive);
18
+ },
19
+ });
20
+
21
+ export const upsertToken = internalMutation({
22
+ args: {
23
+ userId: v.string(),
24
+ epaycoTokenId: v.string(),
25
+ epaycoCustomerId: v.string(),
26
+ mask: v.string(),
27
+ franchise: v.string(),
28
+ isActive: v.boolean(),
29
+ lastSyncedAt: v.number(),
30
+ },
31
+ returns: v.id("tokens"),
32
+ handler: async (ctx, args) => {
33
+ const existing = await ctx.db
34
+ .query("tokens")
35
+ .withIndex("by_epaycoTokenId", (q) =>
36
+ q.eq("epaycoTokenId", args.epaycoTokenId),
37
+ )
38
+ .first();
39
+
40
+ if (existing) {
41
+ if (existing.lastSyncedAt >= args.lastSyncedAt) return existing._id;
42
+
43
+ const patch: Record<string, unknown> = {
44
+ mask: args.mask,
45
+ franchise: args.franchise,
46
+ isActive: args.isActive,
47
+ lastSyncedAt: args.lastSyncedAt,
48
+ };
49
+ // Only link/relink the customer when a non-empty id is provided, so a
50
+ // later card-only sync can't wipe an existing customer association.
51
+ if (args.epaycoCustomerId !== "") {
52
+ patch.epaycoCustomerId = args.epaycoCustomerId;
53
+ }
54
+ await ctx.db.patch(existing._id, patch);
55
+ return existing._id;
56
+ }
57
+
58
+ return await ctx.db.insert("tokens", {
59
+ userId: args.userId,
60
+ epaycoTokenId: args.epaycoTokenId,
61
+ epaycoCustomerId: args.epaycoCustomerId,
62
+ mask: args.mask,
63
+ franchise: args.franchise,
64
+ isActive: args.isActive,
65
+ lastSyncedAt: args.lastSyncedAt,
66
+ });
67
+ },
68
+ });
69
+
70
+ export const revokeToken = internalMutation({
71
+ args: { epaycoTokenId: v.string(), lastSyncedAt: v.number() },
72
+ returns: v.null(),
73
+ handler: async (ctx, args) => {
74
+ const existing = await ctx.db
75
+ .query("tokens")
76
+ .withIndex("by_epaycoTokenId", (q) =>
77
+ q.eq("epaycoTokenId", args.epaycoTokenId),
78
+ )
79
+ .first();
80
+
81
+ if (existing) {
82
+ await ctx.db.patch(existing._id, {
83
+ isActive: false,
84
+ lastSyncedAt: args.lastSyncedAt,
85
+ });
86
+ }
87
+ return null;
88
+ },
89
+ });
@@ -0,0 +1,63 @@
1
+ import { v } from "convex/values";
2
+ import { action } from "./_generated/server.js";
3
+ import { internal } from "./_generated/api.js";
4
+ import { epaycoCredentialsValidator, tokenInfoValidator } from "./validators.js";
5
+ import { getEpaycoClient, unwrap, dataOf, pick } from "./epaycoClient.js";
6
+ import { rateLimiter } from "./rateLimits.js";
7
+
8
+ /** Tokenize a card via ePayco and cache the resulting token locally. */
9
+ export const createToken = action({
10
+ args: {
11
+ credentials: epaycoCredentialsValidator,
12
+ userId: v.string(),
13
+ tokenInfo: tokenInfoValidator,
14
+ },
15
+ returns: v.any(),
16
+ handler: async (ctx, args) => {
17
+ await rateLimiter.limit(ctx, "createToken", {
18
+ key: args.userId,
19
+ throws: true,
20
+ });
21
+
22
+ const epayco = getEpaycoClient(args.credentials);
23
+ const result = unwrap(
24
+ await epayco.token.create({
25
+ "card[number]": args.tokenInfo.cardNumber,
26
+ "card[exp_year]": args.tokenInfo.expYear,
27
+ "card[exp_month]": args.tokenInfo.expMonth,
28
+ "card[cvc]": args.tokenInfo.cvc,
29
+ hasCvv: args.tokenInfo.hasCvv ?? true,
30
+ }),
31
+ );
32
+
33
+ const data = dataOf(result);
34
+ // ePayco returns the card details (mask, franchise name) under `result.card`.
35
+ const card =
36
+ typeof result.card === "object" && result.card !== null
37
+ ? (result.card as Record<string, unknown>)
38
+ : {};
39
+ const tokenId =
40
+ pick(data, ["id", "token", "tokenId"]) ??
41
+ pick(result, ["id", "token", "tokenId"]);
42
+
43
+ if (tokenId) {
44
+ await ctx.runMutation(internal.tokens.upsertToken, {
45
+ userId: args.userId,
46
+ epaycoTokenId: tokenId,
47
+ epaycoCustomerId: "",
48
+ mask:
49
+ pick(data, ["mask"]) ??
50
+ pick(card, ["mask"]) ??
51
+ `****${args.tokenInfo.cardNumber.slice(-4)}`,
52
+ franchise:
53
+ pick(data, ["franchise"]) ??
54
+ pick(card, ["name", "franchise"]) ??
55
+ "unknown",
56
+ isActive: true,
57
+ lastSyncedAt: Date.now(),
58
+ });
59
+ }
60
+
61
+ return result;
62
+ },
63
+ });
@@ -0,0 +1,227 @@
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("transactions", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ test("upsertTransaction creates a new transaction", async () => {
15
+ const t = initConvexTest();
16
+ const id = await t.mutation(internal.transactions.upsertTransaction, {
17
+ userId: "user1",
18
+ epaycoRef: "ref_001",
19
+ paymentMethod: "credit_card",
20
+ status: "approved",
21
+ amount: 50000,
22
+ currency: "COP",
23
+ description: "Test payment",
24
+ lastSyncedAt: 1000,
25
+ });
26
+ expect(id).toBeDefined();
27
+
28
+ const tx = await t.query(api.transactions.getLocalTransaction, {
29
+ epaycoRef: "ref_001",
30
+ });
31
+ expect(tx).not.toBeNull();
32
+ expect(tx!.amount).toBe(50000);
33
+ expect(tx!.status).toBe("approved");
34
+ expect(tx!.paymentMethod).toBe("credit_card");
35
+ });
36
+
37
+ test("upsertTransaction is idempotent", async () => {
38
+ const t = initConvexTest();
39
+ await t.mutation(internal.transactions.upsertTransaction, {
40
+ userId: "user1",
41
+ epaycoRef: "ref_002",
42
+ paymentMethod: "pse",
43
+ status: "pending",
44
+ amount: 100000,
45
+ currency: "COP",
46
+ description: "PSE payment",
47
+ lastSyncedAt: 1000,
48
+ });
49
+
50
+ // Same timestamp - should not update
51
+ await t.mutation(internal.transactions.upsertTransaction, {
52
+ userId: "user1",
53
+ epaycoRef: "ref_002",
54
+ paymentMethod: "pse",
55
+ status: "approved",
56
+ amount: 100000,
57
+ currency: "COP",
58
+ description: "PSE payment",
59
+ lastSyncedAt: 1000,
60
+ });
61
+
62
+ const tx = await t.query(api.transactions.getLocalTransaction, {
63
+ epaycoRef: "ref_002",
64
+ });
65
+ expect(tx!.status).toBe("pending");
66
+ });
67
+
68
+ test("upsertTransaction updates with newer timestamp", async () => {
69
+ const t = initConvexTest();
70
+ await t.mutation(internal.transactions.upsertTransaction, {
71
+ userId: "user1",
72
+ epaycoRef: "ref_003",
73
+ paymentMethod: "cash",
74
+ status: "pending",
75
+ amount: 75000,
76
+ currency: "COP",
77
+ description: "Cash payment",
78
+ lastSyncedAt: 1000,
79
+ });
80
+
81
+ await t.mutation(internal.transactions.upsertTransaction, {
82
+ userId: "user1",
83
+ epaycoRef: "ref_003",
84
+ paymentMethod: "cash",
85
+ status: "approved",
86
+ amount: 75000,
87
+ currency: "COP",
88
+ description: "Cash payment",
89
+ lastSyncedAt: 2000,
90
+ });
91
+
92
+ const tx = await t.query(api.transactions.getLocalTransaction, {
93
+ epaycoRef: "ref_003",
94
+ });
95
+ expect(tx!.status).toBe("approved");
96
+ });
97
+
98
+ test("listLocalTransactions filters by userId", async () => {
99
+ const t = initConvexTest();
100
+ await t.mutation(internal.transactions.upsertTransaction, {
101
+ userId: "user1",
102
+ epaycoRef: "ref_100",
103
+ paymentMethod: "credit_card",
104
+ status: "approved",
105
+ amount: 10000,
106
+ currency: "COP",
107
+ description: "TX 1",
108
+ lastSyncedAt: 1000,
109
+ });
110
+ await t.mutation(internal.transactions.upsertTransaction, {
111
+ userId: "user2",
112
+ epaycoRef: "ref_101",
113
+ paymentMethod: "pse",
114
+ status: "approved",
115
+ amount: 20000,
116
+ currency: "COP",
117
+ description: "TX 2",
118
+ lastSyncedAt: 1000,
119
+ });
120
+
121
+ const user1Txs = await t.query(api.transactions.listLocalTransactions, {
122
+ userId: "user1",
123
+ });
124
+ expect(user1Txs).toHaveLength(1);
125
+ expect(user1Txs[0].epaycoRef).toBe("ref_100");
126
+ });
127
+
128
+ test("listLocalTransactions filters by status", async () => {
129
+ const t = initConvexTest();
130
+ await t.mutation(internal.transactions.upsertTransaction, {
131
+ userId: "user1",
132
+ epaycoRef: "ref_200",
133
+ paymentMethod: "credit_card",
134
+ status: "approved",
135
+ amount: 10000,
136
+ currency: "COP",
137
+ description: "Approved TX",
138
+ lastSyncedAt: 1000,
139
+ });
140
+ await t.mutation(internal.transactions.upsertTransaction, {
141
+ userId: "user1",
142
+ epaycoRef: "ref_201",
143
+ paymentMethod: "credit_card",
144
+ status: "pending",
145
+ amount: 20000,
146
+ currency: "COP",
147
+ description: "Pending TX",
148
+ lastSyncedAt: 1000,
149
+ });
150
+
151
+ const approvedTxs = await t.query(api.transactions.listLocalTransactions, {
152
+ userId: "user1",
153
+ status: "approved",
154
+ });
155
+ expect(approvedTxs).toHaveLength(1);
156
+ expect(approvedTxs[0].status).toBe("approved");
157
+ });
158
+
159
+ test("updateTransactionStatus updates existing transaction", async () => {
160
+ const t = initConvexTest();
161
+ await t.mutation(internal.transactions.upsertTransaction, {
162
+ userId: "user1",
163
+ epaycoRef: "ref_300",
164
+ paymentMethod: "pse",
165
+ status: "pending",
166
+ amount: 50000,
167
+ currency: "COP",
168
+ description: "PSE TX",
169
+ lastSyncedAt: 1000,
170
+ });
171
+
172
+ await t.mutation(internal.transactions.updateTransactionStatus, {
173
+ epaycoRef: "ref_300",
174
+ status: "approved",
175
+ responseCode: "1",
176
+ responseMessage: "Aprobada",
177
+ });
178
+
179
+ const tx = await t.query(api.transactions.getLocalTransaction, {
180
+ epaycoRef: "ref_300",
181
+ });
182
+ expect(tx!.status).toBe("approved");
183
+ expect(tx!.responseCode).toBe("1");
184
+ });
185
+
186
+ test("upsertTransaction drains a confirmation that arrived first", async () => {
187
+ const t = initConvexTest();
188
+ // A verified confirmation lands before the local transaction exists, so the
189
+ // webhook handler parked it as a pending event.
190
+ await t.mutation(internal.webhooks.storeWebhookEvent, {
191
+ epaycoRef: "ref_race",
192
+ eventType: "confirmation",
193
+ status: "pending",
194
+ rawPayload: {
195
+ x_ref_payco: "ref_race",
196
+ x_cod_response: "1",
197
+ x_response_reason_text: "Aprobada",
198
+ },
199
+ lastSyncedAt: 1000,
200
+ });
201
+
202
+ // The charge action now persists the transaction (initially pending).
203
+ await t.mutation(internal.transactions.upsertTransaction, {
204
+ userId: "user1",
205
+ epaycoRef: "ref_race",
206
+ paymentMethod: "pse",
207
+ status: "pending",
208
+ amount: 50000,
209
+ currency: "COP",
210
+ description: "PSE TX",
211
+ lastSyncedAt: 2000,
212
+ });
213
+
214
+ // The waiting confirmation is applied on insert, not dropped.
215
+ const tx = await t.query(api.transactions.getLocalTransaction, {
216
+ epaycoRef: "ref_race",
217
+ });
218
+ expect(tx!.status).toBe("approved");
219
+ expect(tx!.responseCode).toBe("1");
220
+ expect(tx!.responseMessage).toBe("Aprobada");
221
+
222
+ const event = await t.query(internal.webhooks.getWebhookEvent, {
223
+ epaycoRef: "ref_race",
224
+ });
225
+ expect(event!.status).toBe("processed");
226
+ });
227
+ });
@@ -0,0 +1,200 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, query } from "./_generated/server.js";
3
+ import { paymentMethodValidator } from "./validators.js";
4
+ import { statusFromCodResponse } from "./status.js";
5
+
6
+ export const getLocalTransaction = query({
7
+ args: { epaycoRef: v.string() },
8
+ returns: v.any(),
9
+ handler: async (ctx, args) => {
10
+ return await ctx.db
11
+ .query("transactions")
12
+ .withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
13
+ .first();
14
+ },
15
+ });
16
+
17
+ export const listLocalTransactions = query({
18
+ args: {
19
+ userId: v.string(),
20
+ status: v.optional(v.string()),
21
+ paymentMethod: v.optional(paymentMethodValidator),
22
+ limit: v.optional(v.number()),
23
+ },
24
+ returns: v.any(),
25
+ handler: async (ctx, args) => {
26
+ let q;
27
+ if (args.status) {
28
+ q = ctx.db
29
+ .query("transactions")
30
+ .withIndex("by_userId_and_status", (q) =>
31
+ q.eq("userId", args.userId).eq("status", args.status!),
32
+ );
33
+ } else {
34
+ q = ctx.db
35
+ .query("transactions")
36
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId));
37
+ }
38
+
39
+ if (args.paymentMethod) {
40
+ q = q.filter((q) =>
41
+ q.eq(q.field("paymentMethod"), args.paymentMethod),
42
+ );
43
+ }
44
+
45
+ return await q.order("desc").take(args.limit ?? 100);
46
+ },
47
+ });
48
+
49
+ export const upsertTransaction = internalMutation({
50
+ args: {
51
+ userId: v.string(),
52
+ epaycoRef: v.string(),
53
+ epaycoTransactionId: v.optional(v.string()),
54
+ paymentMethod: paymentMethodValidator,
55
+ status: v.string(),
56
+ amount: v.number(),
57
+ currency: v.string(),
58
+ description: v.string(),
59
+ customerEmail: v.optional(v.string()),
60
+ bankName: v.optional(v.string()),
61
+ cashProvider: v.optional(v.string()),
62
+ responseCode: v.optional(v.string()),
63
+ responseMessage: v.optional(v.string()),
64
+ franchise: v.optional(v.string()),
65
+ splitPayment: v.optional(v.boolean()),
66
+ splitReceivers: v.optional(
67
+ v.array(
68
+ v.object({
69
+ id: v.string(),
70
+ total: v.number(),
71
+ iva: v.number(),
72
+ base_iva: v.number(),
73
+ fee: v.optional(v.number()),
74
+ }),
75
+ ),
76
+ ),
77
+ rawResponse: v.optional(v.any()),
78
+ lastSyncedAt: v.number(),
79
+ },
80
+ handler: async (ctx, args) => {
81
+ const existing = await ctx.db
82
+ .query("transactions")
83
+ .withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
84
+ .first();
85
+
86
+ if (existing) {
87
+ if (existing.lastSyncedAt >= args.lastSyncedAt) return existing._id;
88
+
89
+ await ctx.db.patch(existing._id, {
90
+ status: args.status,
91
+ responseCode: args.responseCode,
92
+ responseMessage: args.responseMessage,
93
+ rawResponse: args.rawResponse,
94
+ lastSyncedAt: args.lastSyncedAt,
95
+ });
96
+ return existing._id;
97
+ }
98
+
99
+ const insertedId = await ctx.db.insert("transactions", {
100
+ userId: args.userId,
101
+ epaycoRef: args.epaycoRef,
102
+ epaycoTransactionId: args.epaycoTransactionId,
103
+ paymentMethod: args.paymentMethod,
104
+ status: args.status,
105
+ amount: args.amount,
106
+ currency: args.currency,
107
+ description: args.description,
108
+ customerEmail: args.customerEmail,
109
+ bankName: args.bankName,
110
+ cashProvider: args.cashProvider,
111
+ responseCode: args.responseCode,
112
+ responseMessage: args.responseMessage,
113
+ franchise: args.franchise,
114
+ splitPayment: args.splitPayment,
115
+ splitReceivers: args.splitReceivers,
116
+ rawResponse: args.rawResponse,
117
+ lastSyncedAt: args.lastSyncedAt,
118
+ });
119
+
120
+ // Reconcile a confirmation that arrived BEFORE this row existed: an async
121
+ // PSE/cash/Daviplata webhook can race ahead of (or be independent of) local
122
+ // persistence, in which case `webhooks.processConfirmation` verified it and
123
+ // parked the event as `pending`. Apply it now so the status is never lost.
124
+ await drainPendingConfirmation(ctx, args.epaycoRef, insertedId);
125
+
126
+ return insertedId;
127
+ },
128
+ });
129
+
130
+ /**
131
+ * If a verified-but-unapplied confirmation event is waiting for `epaycoRef`,
132
+ * apply its outcome to the (now-existing) transaction row and mark the event
133
+ * processed. Deterministic, so no cron/scheduler is needed; it runs whenever a
134
+ * transaction is first inserted. Safe no-op when nothing is waiting.
135
+ */
136
+ async function drainPendingConfirmation(
137
+ ctx: { db: import("./_generated/server.js").MutationCtx["db"] },
138
+ epaycoRef: string,
139
+ transactionId: import("./_generated/dataModel.js").Id<"transactions">,
140
+ ): Promise<void> {
141
+ const event = await ctx.db
142
+ .query("webhookEvents")
143
+ .withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", epaycoRef))
144
+ .order("desc")
145
+ .first();
146
+ if (!event || event.status !== "pending") return;
147
+
148
+ const payload = (event.rawPayload ?? {}) as Record<string, unknown>;
149
+ const cod = payload.x_cod_response;
150
+ if (cod === undefined || cod === null) return;
151
+
152
+ const reason = payload.x_response_reason_text ?? payload.x_response;
153
+ await ctx.db.patch(transactionId, {
154
+ status: statusFromCodResponse(String(cod)),
155
+ responseCode: String(cod),
156
+ responseMessage:
157
+ reason === undefined || reason === null ? undefined : String(reason),
158
+ rawResponse: payload,
159
+ lastSyncedAt: Date.now(),
160
+ });
161
+ await ctx.db.patch(event._id, {
162
+ status: "processed",
163
+ processedAt: Date.now(),
164
+ lastSyncedAt: Date.now(),
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Patch the status of an existing transaction. Returns `true` when a row was
170
+ * found and updated, `false` when no local row exists yet — so callers (e.g. the
171
+ * webhook handler) can detect a confirmation that arrived before local
172
+ * persistence instead of silently dropping it.
173
+ */
174
+ export const updateTransactionStatus = internalMutation({
175
+ args: {
176
+ epaycoRef: v.string(),
177
+ status: v.string(),
178
+ responseCode: v.optional(v.string()),
179
+ responseMessage: v.optional(v.string()),
180
+ rawResponse: v.optional(v.any()),
181
+ },
182
+ returns: v.boolean(),
183
+ handler: async (ctx, args) => {
184
+ const existing = await ctx.db
185
+ .query("transactions")
186
+ .withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
187
+ .first();
188
+
189
+ if (!existing) return false;
190
+
191
+ await ctx.db.patch(existing._id, {
192
+ status: args.status,
193
+ responseCode: args.responseCode,
194
+ responseMessage: args.responseMessage,
195
+ rawResponse: args.rawResponse,
196
+ lastSyncedAt: Date.now(),
197
+ });
198
+ return true;
199
+ },
200
+ });