@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.
- package/LICENSE +201 -0
- package/README.md +945 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +222 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +355 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +78 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +580 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/banks.d.ts +14 -0
- package/dist/component/banks.d.ts.map +1 -0
- package/dist/component/banks.js +37 -0
- package/dist/component/banks.js.map +1 -0
- package/dist/component/cashApi.d.ts +59 -0
- package/dist/component/cashApi.d.ts.map +1 -0
- package/dist/component/cashApi.js +88 -0
- package/dist/component/cashApi.js.map +1 -0
- package/dist/component/chargesApi.d.ts +64 -0
- package/dist/component/chargesApi.d.ts.map +1 -0
- package/dist/component/chargesApi.js +106 -0
- package/dist/component/chargesApi.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +6 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/customers.d.ts +67 -0
- package/dist/component/customers.d.ts.map +1 -0
- package/dist/component/customers.js +103 -0
- package/dist/component/customers.js.map +1 -0
- package/dist/component/customersApi.d.ts +99 -0
- package/dist/component/customersApi.d.ts.map +1 -0
- package/dist/component/customersApi.js +176 -0
- package/dist/component/customersApi.js.map +1 -0
- package/dist/component/daviplataApi.d.ts +43 -0
- package/dist/component/daviplataApi.d.ts.map +1 -0
- package/dist/component/daviplataApi.js +103 -0
- package/dist/component/daviplataApi.js.map +1 -0
- package/dist/component/epaycoClient.d.ts +84 -0
- package/dist/component/epaycoClient.d.ts.map +1 -0
- package/dist/component/epaycoClient.js +422 -0
- package/dist/component/epaycoClient.js.map +1 -0
- package/dist/component/payloads.d.ts +34 -0
- package/dist/component/payloads.d.ts.map +1 -0
- package/dist/component/payloads.js +45 -0
- package/dist/component/payloads.js.map +1 -0
- package/dist/component/plans.d.ts +47 -0
- package/dist/component/plans.d.ts.map +1 -0
- package/dist/component/plans.js +83 -0
- package/dist/component/plans.js.map +1 -0
- package/dist/component/plansApi.d.ts +64 -0
- package/dist/component/plansApi.d.ts.map +1 -0
- package/dist/component/plansApi.js +121 -0
- package/dist/component/plansApi.js.map +1 -0
- package/dist/component/pseApi.d.ts +68 -0
- package/dist/component/pseApi.d.ts.map +1 -0
- package/dist/component/pseApi.js +113 -0
- package/dist/component/pseApi.js.map +1 -0
- package/dist/component/rateLimits.d.ts +69 -0
- package/dist/component/rateLimits.d.ts.map +1 -0
- package/dist/component/rateLimits.js +67 -0
- package/dist/component/rateLimits.js.map +1 -0
- package/dist/component/safetypayApi.d.ts +35 -0
- package/dist/component/safetypayApi.d.ts.map +1 -0
- package/dist/component/safetypayApi.js +68 -0
- package/dist/component/safetypayApi.js.map +1 -0
- package/dist/component/schema.d.ts +200 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +104 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/signature.d.ts +11 -0
- package/dist/component/signature.d.ts.map +1 -0
- package/dist/component/signature.js +28 -0
- package/dist/component/signature.js.map +1 -0
- package/dist/component/status.d.ts +12 -0
- package/dist/component/status.d.ts.map +1 -0
- package/dist/component/status.js +55 -0
- package/dist/component/status.js.map +1 -0
- package/dist/component/subscriptions.d.ts +69 -0
- package/dist/component/subscriptions.d.ts.map +1 -0
- package/dist/component/subscriptions.js +114 -0
- package/dist/component/subscriptions.js.map +1 -0
- package/dist/component/subscriptionsApi.d.ts +62 -0
- package/dist/component/subscriptionsApi.d.ts.map +1 -0
- package/dist/component/subscriptionsApi.js +147 -0
- package/dist/component/subscriptionsApi.js.map +1 -0
- package/dist/component/tokens.d.ts +31 -0
- package/dist/component/tokens.d.ts.map +1 -0
- package/dist/component/tokens.js +79 -0
- package/dist/component/tokens.js.map +1 -0
- package/dist/component/tokensApi.d.ts +18 -0
- package/dist/component/tokensApi.d.ts.map +1 -0
- package/dist/component/tokensApi.js +53 -0
- package/dist/component/tokensApi.js.map +1 -0
- package/dist/component/transactions.d.ts +103 -0
- package/dist/component/transactions.d.ts.map +1 -0
- package/dist/component/transactions.js +177 -0
- package/dist/component/transactions.js.map +1 -0
- package/dist/component/validators.d.ts +571 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +203 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/component/webhooks.d.ts +55 -0
- package/dist/component/webhooks.d.ts.map +1 -0
- package/dist/component/webhooks.js +172 -0
- package/dist/component/webhooks.js.map +1 -0
- package/dist/react/index.d.ts +16 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +43 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +106 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.test.ts +66 -0
- package/src/client/index.ts +633 -0
- package/src/client/setup.test.ts +26 -0
- package/src/component/_generated/api.ts +94 -0
- package/src/component/_generated/component.ts +809 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/banks.ts +41 -0
- package/src/component/cashApi.ts +100 -0
- package/src/component/chargesApi.ts +119 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/customers.test.ts +122 -0
- package/src/component/customers.ts +116 -0
- package/src/component/customersApi.ts +206 -0
- package/src/component/daviplataApi.ts +119 -0
- package/src/component/epaycoApi.test.ts +110 -0
- package/src/component/epaycoClient.ts +578 -0
- package/src/component/payloads.ts +67 -0
- package/src/component/plans.test.ts +129 -0
- package/src/component/plans.ts +86 -0
- package/src/component/plansApi.ts +135 -0
- package/src/component/pseApi.ts +125 -0
- package/src/component/rateLimits.ts +67 -0
- package/src/component/safetypayApi.ts +78 -0
- package/src/component/schema.ts +124 -0
- package/src/component/setup.test.helper.ts +10 -0
- package/src/component/setup.test.ts +22 -0
- package/src/component/signature.ts +38 -0
- package/src/component/status.ts +71 -0
- package/src/component/subscriptions.test.ts +117 -0
- package/src/component/subscriptions.ts +128 -0
- package/src/component/subscriptionsApi.ts +172 -0
- package/src/component/tokens.ts +89 -0
- package/src/component/tokensApi.ts +63 -0
- package/src/component/transactions.test.ts +227 -0
- package/src/component/transactions.ts +200 -0
- package/src/component/validators.ts +245 -0
- package/src/component/webhooks.test.ts +137 -0
- package/src/component/webhooks.ts +229 -0
- package/src/react/index.ts +71 -0
- package/src/test.ts +13 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Credentials accepted by every component action. The host app passes these in
|
|
5
|
+
* from its own environment (see `EPayco` client wrapper). `apiKey` is the
|
|
6
|
+
* ePayco PUBLIC_KEY and `privateKey` is the PRIVATE_KEY.
|
|
7
|
+
*/
|
|
8
|
+
export const epaycoCredentialsValidator = v.object({
|
|
9
|
+
apiKey: v.string(),
|
|
10
|
+
privateKey: v.string(),
|
|
11
|
+
testMode: v.optional(v.boolean()),
|
|
12
|
+
lang: v.optional(v.string()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type EPaycoCredentials = {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
testMode?: boolean;
|
|
19
|
+
lang?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A split-payment receiver (used by charge / PSE / cash dispersion). */
|
|
23
|
+
export const splitReceiverValidator = v.object({
|
|
24
|
+
id: v.string(),
|
|
25
|
+
total: v.string(),
|
|
26
|
+
iva: v.string(),
|
|
27
|
+
base_iva: v.string(),
|
|
28
|
+
fee: v.optional(v.string()),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Optional split-payment configuration shared by charge / PSE / cash. */
|
|
32
|
+
export const splitPaymentValidator = v.object({
|
|
33
|
+
splitType: v.optional(v.string()),
|
|
34
|
+
splitAppId: v.optional(v.string()),
|
|
35
|
+
splitMerchantId: v.optional(v.string()),
|
|
36
|
+
splitPrimaryReceiver: v.optional(v.string()),
|
|
37
|
+
splitPrimaryReceiverFee: v.optional(v.string()),
|
|
38
|
+
splitRule: v.optional(v.string()),
|
|
39
|
+
splitReceivers: v.optional(v.array(splitReceiverValidator)),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const customerInfoValidator = v.object({
|
|
43
|
+
tokenCard: v.string(),
|
|
44
|
+
name: v.string(),
|
|
45
|
+
lastName: v.optional(v.string()),
|
|
46
|
+
email: v.string(),
|
|
47
|
+
phone: v.optional(v.string()),
|
|
48
|
+
cellPhone: v.optional(v.string()),
|
|
49
|
+
docType: v.optional(v.string()),
|
|
50
|
+
docNumber: v.optional(v.string()),
|
|
51
|
+
isDefault: v.optional(v.boolean()),
|
|
52
|
+
city: v.optional(v.string()),
|
|
53
|
+
address: v.optional(v.string()),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const tokenInfoValidator = v.object({
|
|
57
|
+
cardNumber: v.string(),
|
|
58
|
+
expYear: v.string(),
|
|
59
|
+
expMonth: v.string(),
|
|
60
|
+
cvc: v.string(),
|
|
61
|
+
hasCvv: v.optional(v.boolean()),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const chargeInfoValidator = v.object({
|
|
65
|
+
tokenCard: v.string(),
|
|
66
|
+
customerId: v.string(),
|
|
67
|
+
docType: v.string(),
|
|
68
|
+
docNumber: v.string(),
|
|
69
|
+
name: v.string(),
|
|
70
|
+
lastName: v.string(),
|
|
71
|
+
email: v.string(),
|
|
72
|
+
city: v.optional(v.string()),
|
|
73
|
+
address: v.optional(v.string()),
|
|
74
|
+
phone: v.optional(v.string()),
|
|
75
|
+
cellPhone: v.optional(v.string()),
|
|
76
|
+
bill: v.string(),
|
|
77
|
+
description: v.string(),
|
|
78
|
+
value: v.number(),
|
|
79
|
+
tax: v.number(),
|
|
80
|
+
taxBase: v.number(),
|
|
81
|
+
currency: v.optional(v.string()),
|
|
82
|
+
dues: v.optional(v.number()),
|
|
83
|
+
ip: v.optional(v.string()),
|
|
84
|
+
urlResponse: v.optional(v.string()),
|
|
85
|
+
urlConfirmation: v.optional(v.string()),
|
|
86
|
+
methodConfirmation: v.optional(v.string()),
|
|
87
|
+
useDefaultCardCustomer: v.optional(v.boolean()),
|
|
88
|
+
extra1: v.optional(v.string()),
|
|
89
|
+
extra2: v.optional(v.string()),
|
|
90
|
+
extra3: v.optional(v.string()),
|
|
91
|
+
split: v.optional(splitPaymentValidator),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const pseInfoValidator = v.object({
|
|
95
|
+
bank: v.string(),
|
|
96
|
+
typePerson: v.union(v.literal("0"), v.literal("1")),
|
|
97
|
+
docType: v.string(),
|
|
98
|
+
docNumber: v.string(),
|
|
99
|
+
name: v.string(),
|
|
100
|
+
lastName: v.string(),
|
|
101
|
+
email: v.string(),
|
|
102
|
+
cellPhone: v.string(),
|
|
103
|
+
country: v.optional(v.string()),
|
|
104
|
+
bill: v.string(),
|
|
105
|
+
description: v.string(),
|
|
106
|
+
value: v.number(),
|
|
107
|
+
tax: v.number(),
|
|
108
|
+
taxBase: v.number(),
|
|
109
|
+
currency: v.optional(v.string()),
|
|
110
|
+
ip: v.optional(v.string()),
|
|
111
|
+
urlResponse: v.optional(v.string()),
|
|
112
|
+
urlConfirmation: v.optional(v.string()),
|
|
113
|
+
extra1: v.optional(v.string()),
|
|
114
|
+
extra2: v.optional(v.string()),
|
|
115
|
+
extra3: v.optional(v.string()),
|
|
116
|
+
split: v.optional(splitPaymentValidator),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const cashProviderValidator = v.union(
|
|
120
|
+
v.literal("efecty"),
|
|
121
|
+
v.literal("baloto"),
|
|
122
|
+
v.literal("gana"),
|
|
123
|
+
v.literal("redservi"),
|
|
124
|
+
v.literal("puntored"),
|
|
125
|
+
v.literal("sured"),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
export const cashInfoValidator = v.object({
|
|
129
|
+
docType: v.string(),
|
|
130
|
+
docNumber: v.string(),
|
|
131
|
+
typePerson: v.optional(v.union(v.literal("0"), v.literal("1"))),
|
|
132
|
+
name: v.string(),
|
|
133
|
+
lastName: v.string(),
|
|
134
|
+
email: v.string(),
|
|
135
|
+
cellPhone: v.string(),
|
|
136
|
+
bill: v.string(),
|
|
137
|
+
description: v.string(),
|
|
138
|
+
value: v.number(),
|
|
139
|
+
tax: v.number(),
|
|
140
|
+
taxBase: v.number(),
|
|
141
|
+
currency: v.optional(v.string()),
|
|
142
|
+
ip: v.optional(v.string()),
|
|
143
|
+
urlResponse: v.optional(v.string()),
|
|
144
|
+
urlConfirmation: v.optional(v.string()),
|
|
145
|
+
endDate: v.optional(v.string()),
|
|
146
|
+
extra1: v.optional(v.string()),
|
|
147
|
+
extra2: v.optional(v.string()),
|
|
148
|
+
extra3: v.optional(v.string()),
|
|
149
|
+
split: v.optional(splitPaymentValidator),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const daviplataInfoValidator = v.object({
|
|
153
|
+
docType: v.string(),
|
|
154
|
+
docNumber: v.string(),
|
|
155
|
+
name: v.string(),
|
|
156
|
+
lastName: v.string(),
|
|
157
|
+
email: v.string(),
|
|
158
|
+
indCountry: v.optional(v.string()),
|
|
159
|
+
phone: v.string(),
|
|
160
|
+
country: v.optional(v.string()),
|
|
161
|
+
city: v.optional(v.string()),
|
|
162
|
+
address: v.optional(v.string()),
|
|
163
|
+
ip: v.optional(v.string()),
|
|
164
|
+
description: v.string(),
|
|
165
|
+
value: v.number(),
|
|
166
|
+
tax: v.number(),
|
|
167
|
+
taxBase: v.number(),
|
|
168
|
+
currency: v.optional(v.string()),
|
|
169
|
+
methodConfirmation: v.optional(v.string()),
|
|
170
|
+
urlConfirmation: v.optional(v.string()),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export const safetypayInfoValidator = v.object({
|
|
174
|
+
cash: v.union(v.literal("1"), v.literal("2")),
|
|
175
|
+
endDate: v.optional(v.string()),
|
|
176
|
+
docType: v.string(),
|
|
177
|
+
docNumber: v.string(),
|
|
178
|
+
name: v.string(),
|
|
179
|
+
lastName: v.string(),
|
|
180
|
+
email: v.string(),
|
|
181
|
+
indCountry: v.optional(v.string()),
|
|
182
|
+
phone: v.string(),
|
|
183
|
+
country: v.optional(v.string()),
|
|
184
|
+
city: v.optional(v.string()),
|
|
185
|
+
address: v.optional(v.string()),
|
|
186
|
+
invoice: v.optional(v.string()),
|
|
187
|
+
ip: v.optional(v.string()),
|
|
188
|
+
description: v.string(),
|
|
189
|
+
value: v.number(),
|
|
190
|
+
tax: v.number(),
|
|
191
|
+
ico: v.optional(v.number()),
|
|
192
|
+
taxBase: v.number(),
|
|
193
|
+
currency: v.optional(v.string()),
|
|
194
|
+
methodConfirmation: v.optional(v.string()),
|
|
195
|
+
urlConfirmation: v.optional(v.string()),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export const planInfoValidator = v.object({
|
|
199
|
+
idPlan: v.string(),
|
|
200
|
+
name: v.string(),
|
|
201
|
+
description: v.string(),
|
|
202
|
+
amount: v.number(),
|
|
203
|
+
currency: v.string(),
|
|
204
|
+
interval: v.string(),
|
|
205
|
+
intervalCount: v.number(),
|
|
206
|
+
trialDays: v.number(),
|
|
207
|
+
iva: v.optional(v.number()),
|
|
208
|
+
ico: v.optional(v.number()),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export const subscriptionInfoValidator = v.object({
|
|
212
|
+
idPlan: v.string(),
|
|
213
|
+
customer: v.string(),
|
|
214
|
+
tokenCard: v.string(),
|
|
215
|
+
docType: v.string(),
|
|
216
|
+
docNumber: v.string(),
|
|
217
|
+
urlConfirmation: v.optional(v.string()),
|
|
218
|
+
methodConfirmation: v.optional(v.string()),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export const paymentMethodValidator = v.union(
|
|
222
|
+
v.literal("credit_card"),
|
|
223
|
+
v.literal("pse"),
|
|
224
|
+
v.literal("cash"),
|
|
225
|
+
v.literal("daviplata"),
|
|
226
|
+
v.literal("safetypay"),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
export const transactionStatusValidator = v.union(
|
|
230
|
+
v.literal("pending"),
|
|
231
|
+
v.literal("approved"),
|
|
232
|
+
v.literal("rejected"),
|
|
233
|
+
v.literal("failed"),
|
|
234
|
+
v.literal("expired"),
|
|
235
|
+
v.literal("reversed"),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/** Split receivers persisted on a transaction (numeric form for local use). */
|
|
239
|
+
export const storedSplitReceiverValidator = v.object({
|
|
240
|
+
id: v.string(),
|
|
241
|
+
total: v.number(),
|
|
242
|
+
iva: v.number(),
|
|
243
|
+
base_iva: v.number(),
|
|
244
|
+
fee: v.optional(v.number()),
|
|
245
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { internal } from "./_generated/api.js";
|
|
5
|
+
|
|
6
|
+
describe("webhooks", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("storeWebhookEvent creates a new event", async () => {
|
|
15
|
+
const t = initConvexTest();
|
|
16
|
+
const id = await t.mutation(internal.webhooks.storeWebhookEvent, {
|
|
17
|
+
epaycoRef: "ref_w001",
|
|
18
|
+
eventType: "confirmation",
|
|
19
|
+
status: "pending",
|
|
20
|
+
rawPayload: { x_ref_payco: "ref_w001", x_cod_response: "1" },
|
|
21
|
+
lastSyncedAt: 1000,
|
|
22
|
+
});
|
|
23
|
+
expect(id).toBeDefined();
|
|
24
|
+
|
|
25
|
+
const event = await t.query(internal.webhooks.getWebhookEvent, {
|
|
26
|
+
epaycoRef: "ref_w001",
|
|
27
|
+
});
|
|
28
|
+
expect(event).not.toBeNull();
|
|
29
|
+
expect(event!.status).toBe("pending");
|
|
30
|
+
expect(event!.eventType).toBe("confirmation");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("markWebhookProcessed updates event status", async () => {
|
|
34
|
+
const t = initConvexTest();
|
|
35
|
+
await t.mutation(internal.webhooks.storeWebhookEvent, {
|
|
36
|
+
epaycoRef: "ref_w002",
|
|
37
|
+
eventType: "confirmation",
|
|
38
|
+
status: "pending",
|
|
39
|
+
rawPayload: {},
|
|
40
|
+
lastSyncedAt: 1000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await t.mutation(internal.webhooks.markWebhookProcessed, {
|
|
44
|
+
epaycoRef: "ref_w002",
|
|
45
|
+
status: "processed",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const event = await t.query(internal.webhooks.getWebhookEvent, {
|
|
49
|
+
epaycoRef: "ref_w002",
|
|
50
|
+
});
|
|
51
|
+
expect(event!.status).toBe("processed");
|
|
52
|
+
expect(event!.processedAt).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("markWebhookProcessed with error message", async () => {
|
|
56
|
+
const t = initConvexTest();
|
|
57
|
+
await t.mutation(internal.webhooks.storeWebhookEvent, {
|
|
58
|
+
epaycoRef: "ref_w003",
|
|
59
|
+
eventType: "confirmation",
|
|
60
|
+
status: "pending",
|
|
61
|
+
rawPayload: {},
|
|
62
|
+
lastSyncedAt: 1000,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await t.mutation(internal.webhooks.markWebhookProcessed, {
|
|
66
|
+
epaycoRef: "ref_w003",
|
|
67
|
+
status: "failed",
|
|
68
|
+
errorMessage: "Invalid signature",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const event = await t.query(internal.webhooks.getWebhookEvent, {
|
|
72
|
+
epaycoRef: "ref_w003",
|
|
73
|
+
});
|
|
74
|
+
expect(event!.status).toBe("failed");
|
|
75
|
+
expect(event!.errorMessage).toBe("Invalid signature");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("verifyWebhookSignature (pure function)", () => {
|
|
80
|
+
const custIdCliente = "12345";
|
|
81
|
+
const pKey = "secretkey";
|
|
82
|
+
const refPayco = "ref_001";
|
|
83
|
+
const transactionId = "tx_001";
|
|
84
|
+
const amount = "50000";
|
|
85
|
+
const currency = "COP";
|
|
86
|
+
|
|
87
|
+
// Pinned, externally-computed SHA-256 of the canonical wire string
|
|
88
|
+
// `${custId}^${pKey}^${ref}^${txId}^${amount}^${currency}`
|
|
89
|
+
// i.e. SHA256("12345^secretkey^ref_001^tx_001^50000^COP"). Hardcoding it
|
|
90
|
+
// (rather than recomputing with the implementation's own routine) means a
|
|
91
|
+
// regression in field order/separator would actually fail this test.
|
|
92
|
+
const KNOWN_GOOD_SIGNATURE =
|
|
93
|
+
"16514e8ce6f372b83a414bbb9141d3a09c213df702d91819f999c14e0d5b893b";
|
|
94
|
+
|
|
95
|
+
test("accepts the known-good signature", async () => {
|
|
96
|
+
const { verifyWebhookSignature } = await import("./signature.js");
|
|
97
|
+
const isValid = await verifyWebhookSignature(
|
|
98
|
+
custIdCliente,
|
|
99
|
+
pKey,
|
|
100
|
+
refPayco,
|
|
101
|
+
transactionId,
|
|
102
|
+
amount,
|
|
103
|
+
currency,
|
|
104
|
+
KNOWN_GOOD_SIGNATURE,
|
|
105
|
+
);
|
|
106
|
+
expect(isValid).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects a wrong signature", async () => {
|
|
110
|
+
const { verifyWebhookSignature } = await import("./signature.js");
|
|
111
|
+
const isInvalid = await verifyWebhookSignature(
|
|
112
|
+
custIdCliente,
|
|
113
|
+
pKey,
|
|
114
|
+
refPayco,
|
|
115
|
+
transactionId,
|
|
116
|
+
amount,
|
|
117
|
+
currency,
|
|
118
|
+
"wrong_signature",
|
|
119
|
+
);
|
|
120
|
+
expect(isInvalid).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("rejects when a signed field is tampered", async () => {
|
|
124
|
+
const { verifyWebhookSignature } = await import("./signature.js");
|
|
125
|
+
// Same pinned signature, but a different amount must not validate.
|
|
126
|
+
const isInvalid = await verifyWebhookSignature(
|
|
127
|
+
custIdCliente,
|
|
128
|
+
pKey,
|
|
129
|
+
refPayco,
|
|
130
|
+
transactionId,
|
|
131
|
+
"99999",
|
|
132
|
+
currency,
|
|
133
|
+
KNOWN_GOOD_SIGNATURE,
|
|
134
|
+
);
|
|
135
|
+
expect(isInvalid).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import {
|
|
3
|
+
action,
|
|
4
|
+
internalMutation,
|
|
5
|
+
internalQuery,
|
|
6
|
+
} from "./_generated/server.js";
|
|
7
|
+
import { internal } from "./_generated/api.js";
|
|
8
|
+
import { verifyWebhookSignature } from "./signature.js";
|
|
9
|
+
import { statusFromCodResponse } from "./status.js";
|
|
10
|
+
import { rateLimiter } from "./rateLimits.js";
|
|
11
|
+
|
|
12
|
+
export const processConfirmation = action({
|
|
13
|
+
args: {
|
|
14
|
+
custIdCliente: v.string(),
|
|
15
|
+
pKey: v.string(),
|
|
16
|
+
payload: v.any(),
|
|
17
|
+
},
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
const {
|
|
20
|
+
x_ref_payco,
|
|
21
|
+
x_transaction_id,
|
|
22
|
+
x_amount,
|
|
23
|
+
x_currency_code,
|
|
24
|
+
x_signature,
|
|
25
|
+
x_response,
|
|
26
|
+
x_response_reason_text,
|
|
27
|
+
x_cod_response,
|
|
28
|
+
} = args.payload;
|
|
29
|
+
|
|
30
|
+
const refPayco = String(x_ref_payco);
|
|
31
|
+
|
|
32
|
+
// Throttle per ref_payco so abuse on one reference can't starve legitimate
|
|
33
|
+
// confirmations for every other transaction.
|
|
34
|
+
await rateLimiter.limit(ctx, "webhookProcessing", {
|
|
35
|
+
key: refPayco,
|
|
36
|
+
throws: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Idempotency: a fully processed confirmation is a no-op (this is a read,
|
|
40
|
+
// nothing is persisted yet).
|
|
41
|
+
const existingEvent = await ctx.runQuery(
|
|
42
|
+
internal.webhooks.getWebhookEvent,
|
|
43
|
+
{ epaycoRef: refPayco },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (existingEvent && existingEvent.status === "processed") {
|
|
47
|
+
return { success: true, message: "Already processed" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify the signature BEFORE persisting anything, so an unauthenticated
|
|
51
|
+
// caller can never write an arbitrary payload into the table.
|
|
52
|
+
const isValid = await verifyWebhookSignature(
|
|
53
|
+
args.custIdCliente,
|
|
54
|
+
args.pKey,
|
|
55
|
+
refPayco,
|
|
56
|
+
String(x_transaction_id),
|
|
57
|
+
String(x_amount),
|
|
58
|
+
String(x_currency_code),
|
|
59
|
+
x_signature,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!isValid) {
|
|
63
|
+
await ctx.runMutation(internal.webhooks.storeWebhookEvent, {
|
|
64
|
+
epaycoRef: refPayco,
|
|
65
|
+
epaycoTransactionId: x_transaction_id
|
|
66
|
+
? String(x_transaction_id)
|
|
67
|
+
: undefined,
|
|
68
|
+
eventType: "confirmation",
|
|
69
|
+
status: "failed",
|
|
70
|
+
errorMessage: "Invalid signature",
|
|
71
|
+
rawPayload: { x_ref_payco: refPayco },
|
|
72
|
+
lastSyncedAt: Date.now(),
|
|
73
|
+
});
|
|
74
|
+
return { success: false, message: "Invalid signature" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Persist the verified event idempotently (insert-or-patch keyed by ref),
|
|
78
|
+
// so concurrent retries collapse into a single row.
|
|
79
|
+
await ctx.runMutation(internal.webhooks.storeWebhookEvent, {
|
|
80
|
+
epaycoRef: refPayco,
|
|
81
|
+
epaycoTransactionId: x_transaction_id
|
|
82
|
+
? String(x_transaction_id)
|
|
83
|
+
: undefined,
|
|
84
|
+
eventType: "confirmation",
|
|
85
|
+
status: "pending",
|
|
86
|
+
rawPayload: args.payload,
|
|
87
|
+
lastSyncedAt: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const codResponse = String(x_cod_response);
|
|
91
|
+
const status = statusFromCodResponse(codResponse);
|
|
92
|
+
|
|
93
|
+
const reasonText = x_response_reason_text ?? x_response;
|
|
94
|
+
const responseMessage =
|
|
95
|
+
reasonText === undefined || reasonText === null
|
|
96
|
+
? undefined
|
|
97
|
+
: String(reasonText);
|
|
98
|
+
|
|
99
|
+
const updated = await ctx.runMutation(
|
|
100
|
+
internal.transactions.updateTransactionStatus,
|
|
101
|
+
{
|
|
102
|
+
epaycoRef: refPayco,
|
|
103
|
+
status,
|
|
104
|
+
responseCode: codResponse,
|
|
105
|
+
responseMessage,
|
|
106
|
+
rawResponse: args.payload,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!updated) {
|
|
111
|
+
// The confirmation arrived before the local transaction row exists (an
|
|
112
|
+
// async PSE/cash/Daviplata webhook racing ahead of persistence). Park
|
|
113
|
+
// the verified event as "pending" with its payload; when the
|
|
114
|
+
// transaction row is later inserted, `transactions.upsertTransaction`
|
|
115
|
+
// drains and applies it, so the status is never lost.
|
|
116
|
+
await ctx.runMutation(internal.webhooks.markWebhookProcessed, {
|
|
117
|
+
epaycoRef: refPayco,
|
|
118
|
+
status: "pending",
|
|
119
|
+
errorMessage:
|
|
120
|
+
"Transaction not yet persisted; will reconcile on insert",
|
|
121
|
+
});
|
|
122
|
+
return { success: true, status, transactionUpdated: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await ctx.runMutation(internal.webhooks.markWebhookProcessed, {
|
|
126
|
+
epaycoRef: refPayco,
|
|
127
|
+
status: "processed",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return { success: true, status };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Idempotent insert-or-patch keyed by `epaycoRef`. Collapsing to one row per
|
|
136
|
+
* reference means concurrent retries (Convex serializes conflicting mutations
|
|
137
|
+
* and retries, so this is effectively atomic) can't accumulate duplicate event
|
|
138
|
+
* rows. A row already marked `processed` is never regressed.
|
|
139
|
+
*/
|
|
140
|
+
export const storeWebhookEvent = internalMutation({
|
|
141
|
+
args: {
|
|
142
|
+
epaycoRef: v.string(),
|
|
143
|
+
epaycoTransactionId: v.optional(v.string()),
|
|
144
|
+
eventType: v.string(),
|
|
145
|
+
status: v.union(
|
|
146
|
+
v.literal("pending"),
|
|
147
|
+
v.literal("processed"),
|
|
148
|
+
v.literal("failed"),
|
|
149
|
+
),
|
|
150
|
+
rawPayload: v.any(),
|
|
151
|
+
errorMessage: v.optional(v.string()),
|
|
152
|
+
lastSyncedAt: v.number(),
|
|
153
|
+
},
|
|
154
|
+
returns: v.id("webhookEvents"),
|
|
155
|
+
handler: async (ctx, args) => {
|
|
156
|
+
// Select the newest row for this ref, matching getWebhookEvent and
|
|
157
|
+
// markWebhookProcessed so all three operate on the same document even if
|
|
158
|
+
// legacy duplicates exist.
|
|
159
|
+
const existing = await ctx.db
|
|
160
|
+
.query("webhookEvents")
|
|
161
|
+
.withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
|
|
162
|
+
.order("desc")
|
|
163
|
+
.first();
|
|
164
|
+
|
|
165
|
+
if (existing) {
|
|
166
|
+
if (existing.status !== "processed") {
|
|
167
|
+
await ctx.db.patch(existing._id, {
|
|
168
|
+
epaycoTransactionId:
|
|
169
|
+
args.epaycoTransactionId ?? existing.epaycoTransactionId,
|
|
170
|
+
status: args.status,
|
|
171
|
+
rawPayload: args.rawPayload,
|
|
172
|
+
errorMessage: args.errorMessage,
|
|
173
|
+
lastSyncedAt: args.lastSyncedAt,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return existing._id;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return await ctx.db.insert("webhookEvents", {
|
|
180
|
+
epaycoRef: args.epaycoRef,
|
|
181
|
+
epaycoTransactionId: args.epaycoTransactionId,
|
|
182
|
+
eventType: args.eventType,
|
|
183
|
+
status: args.status,
|
|
184
|
+
rawPayload: args.rawPayload,
|
|
185
|
+
errorMessage: args.errorMessage,
|
|
186
|
+
lastSyncedAt: args.lastSyncedAt,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const markWebhookProcessed = internalMutation({
|
|
192
|
+
args: {
|
|
193
|
+
epaycoRef: v.string(),
|
|
194
|
+
status: v.union(
|
|
195
|
+
v.literal("pending"),
|
|
196
|
+
v.literal("processed"),
|
|
197
|
+
v.literal("failed"),
|
|
198
|
+
),
|
|
199
|
+
errorMessage: v.optional(v.string()),
|
|
200
|
+
},
|
|
201
|
+
handler: async (ctx, args) => {
|
|
202
|
+
const event = await ctx.db
|
|
203
|
+
.query("webhookEvents")
|
|
204
|
+
.withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
|
|
205
|
+
.order("desc")
|
|
206
|
+
.first();
|
|
207
|
+
|
|
208
|
+
if (event) {
|
|
209
|
+
await ctx.db.patch(event._id, {
|
|
210
|
+
status: args.status,
|
|
211
|
+
processedAt: Date.now(),
|
|
212
|
+
errorMessage: args.errorMessage,
|
|
213
|
+
lastSyncedAt: Date.now(),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export const getWebhookEvent = internalQuery({
|
|
220
|
+
args: { epaycoRef: v.string() },
|
|
221
|
+
returns: v.any(),
|
|
222
|
+
handler: async (ctx, args) => {
|
|
223
|
+
return await ctx.db
|
|
224
|
+
.query("webhookEvents")
|
|
225
|
+
.withIndex("by_epaycoRef", (q) => q.eq("epaycoRef", args.epaycoRef))
|
|
226
|
+
.order("desc")
|
|
227
|
+
.first();
|
|
228
|
+
},
|
|
229
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery, useAction } from "convex/react";
|
|
4
|
+
import { useState, useCallback } from "react";
|
|
5
|
+
import type { FunctionReference, FunctionArgs, FunctionReturnType } from "convex/server";
|
|
6
|
+
|
|
7
|
+
type AnyQueryRef = FunctionReference<"query", "public", any, any>;
|
|
8
|
+
type AnyActionRef = FunctionReference<"action", "public", any, any>;
|
|
9
|
+
|
|
10
|
+
export function useTransactions<Ref extends AnyQueryRef>(
|
|
11
|
+
ref: Ref,
|
|
12
|
+
args: FunctionArgs<Ref>,
|
|
13
|
+
) {
|
|
14
|
+
return useQuery(ref, args);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useTransaction<Ref extends AnyQueryRef>(
|
|
18
|
+
ref: Ref,
|
|
19
|
+
args: FunctionArgs<Ref>,
|
|
20
|
+
) {
|
|
21
|
+
return useQuery(ref, args);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useSubscriptions<Ref extends AnyQueryRef>(
|
|
25
|
+
ref: Ref,
|
|
26
|
+
args: FunctionArgs<Ref>,
|
|
27
|
+
) {
|
|
28
|
+
return useQuery(ref, args);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useActiveSubscription<Ref extends AnyQueryRef>(
|
|
32
|
+
ref: Ref,
|
|
33
|
+
args: FunctionArgs<Ref>,
|
|
34
|
+
) {
|
|
35
|
+
return useQuery(ref, args);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useCustomer<Ref extends AnyQueryRef>(
|
|
39
|
+
ref: Ref,
|
|
40
|
+
args: FunctionArgs<Ref>,
|
|
41
|
+
) {
|
|
42
|
+
return useQuery(ref, args);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function usePayment<Ref extends AnyActionRef>(ref: Ref) {
|
|
46
|
+
const runAction = useAction(ref);
|
|
47
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
48
|
+
const [error, setError] = useState<Error | null>(null);
|
|
49
|
+
const [result, setResult] = useState<FunctionReturnType<Ref> | null>(null);
|
|
50
|
+
|
|
51
|
+
const execute = useCallback(
|
|
52
|
+
async (args: FunctionArgs<Ref>) => {
|
|
53
|
+
setIsLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
try {
|
|
56
|
+
const res = await runAction(args);
|
|
57
|
+
setResult(res);
|
|
58
|
+
return res;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
61
|
+
setError(e);
|
|
62
|
+
throw e;
|
|
63
|
+
} finally {
|
|
64
|
+
setIsLoading(false);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
[runAction],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return { execute, isLoading, error, result };
|
|
71
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import type { TestConvex } from "convex-test";
|
|
3
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
4
|
+
import schema from "./component/schema.js";
|
|
5
|
+
const modules = import.meta.glob("./component/**/*.ts");
|
|
6
|
+
|
|
7
|
+
export function register(
|
|
8
|
+
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
|
|
9
|
+
name: string = "epayco",
|
|
10
|
+
) {
|
|
11
|
+
t.registerComponent(name, schema, modules);
|
|
12
|
+
}
|
|
13
|
+
export default { register, schema, modules };
|