@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
package/README.md ADDED
@@ -0,0 +1,945 @@
1
+ # @pulgueta/epayco-convex
2
+
3
+ A [Convex](https://convex.dev) component for accepting payments with
4
+ [ePayco](https://epayco.co), Colombia's payment gateway — credit cards, **PSE**
5
+ bank transfers, **cash** vouchers (Efecty, Baloto, …), **Daviplata**, **SafetyPay**,
6
+ and recurring **plans/subscriptions**.
7
+
8
+ It gives you a typed, server-side client (`EPayco`), reactive payment tables that
9
+ sync automatically, webhook handling with signature verification, built-in rate
10
+ limiting, and ready-made React hooks — so you can wire a checkout in minutes and
11
+ keep the live payment state in your UI without polling.
12
+
13
+ ```ts
14
+ // One call from any Convex action — the charge runs, the result is persisted,
15
+ // and every subscribed client re-renders the moment the status changes.
16
+ const charge = await epayco.chargeCreditCard(ctx, { userId, chargeInfo });
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Contents
22
+
23
+ - [What ePayco offers (and how this maps to the component)](#what-epayco-offers)
24
+ - [How it works](#how-it-works)
25
+ - [Install](#install)
26
+ - [Register the component](#register-the-component)
27
+ - [Environment variables](#environment-variables)
28
+ - [Quickstart — wire a card charge in 5 minutes](#quickstart)
29
+ - [Core concepts](#core-concepts)
30
+ - [Client-side wiring with React](#client-side-wiring-with-react)
31
+ - [Payment recipes](#payment-recipes)
32
+ - [Credit cards](#credit-cards)
33
+ - [PSE bank transfer](#pse-bank-transfer)
34
+ - [Cash vouchers](#cash-vouchers)
35
+ - [Daviplata](#daviplata)
36
+ - [SafetyPay](#safetypay)
37
+ - [Plans & subscriptions](#plans--subscriptions)
38
+ - [Split payments (marketplaces)](#split-payments)
39
+ - [Webhooks & payment confirmation](#webhooks--payment-confirmation)
40
+ - [Reactive reads & `exposeApi`](#reactive-reads--exposeapi)
41
+ - [Security model](#security-model)
42
+ - [Integration patterns by app type](#integration-patterns-by-app-type)
43
+ - [Status vocabulary](#status-vocabulary)
44
+ - [Error handling](#error-handling)
45
+ - [API reference](#api-reference)
46
+ - [Database tables](#database-tables)
47
+ - [Rate limits](#rate-limits)
48
+ - [Sandbox & account-gated features](#sandbox--account-gated-features)
49
+ - [Testing](#testing)
50
+ - [License](#license)
51
+
52
+ ---
53
+
54
+ ## What ePayco offers
55
+
56
+ ePayco is a Colombian aggregator. Each payment method has its own flow; this
57
+ component exposes all of them behind one client:
58
+
59
+ | Method | What it is | Component method | Settlement |
60
+ | --- | --- | --- | --- |
61
+ | **Credit/debit card** | Tokenize a card, then charge it | `chargeCreditCard` | Instant (sync) |
62
+ | **PSE** | Online bank debit (redirect to the bank) | `createPseTransaction` | Async confirmation |
63
+ | **Cash** | Voucher paid at Efecty/Baloto/Gana/… | `createCashPayment` | Async confirmation |
64
+ | **Daviplata** | Wallet payment confirmed with an OTP | `createDaviplataPayment` + `confirmDaviplataPayment` | Two-step |
65
+ | **SafetyPay** | Cash or online-bank via SafetyPay | `createSafetyPayPayment` | Async confirmation |
66
+ | **Plans / subscriptions** | Recurring billing on a saved card | `createPlan` / `createSubscription` | Recurring |
67
+ | **Split payments** | Disperse one charge across receivers | `split` on charge/PSE/cash | Per method |
68
+
69
+ For the async methods, ePayco calls your **confirmation webhook** when the
70
+ payment settles; the component verifies the signature and updates the
71
+ transaction row, which your UI is already subscribed to.
72
+
73
+ ---
74
+
75
+ ## How it works
76
+
77
+ Convex components run only in the **V8 runtime**, so this component does **not**
78
+ depend on the Node-only [`epayco-sdk-node`](https://github.com/epayco/epayco-node)
79
+ package. Instead it reimplements ePayco's exact wire protocol natively using Web
80
+ platform APIs (`fetch`, Web Crypto) — JWT login, AES-128-CBC encryption for PSE,
81
+ the Spanish/camelCase field translation, and the apify Basic-auth flow — ported
82
+ field-for-field from the official SDK (`epayco-sdk-node@1.4.4`). The AES output
83
+ is verified **byte-identical** to the SDK's `crypto-js` implementation in the test
84
+ suite, so wire compatibility is locked in. The component runs fully inside Convex
85
+ with **no external runtime dependency**.
86
+
87
+ ---
88
+
89
+ ## Install
90
+
91
+ ```bash
92
+ npm install @pulgueta/epayco-convex convex
93
+ # or: pnpm add @pulgueta/epayco-convex convex
94
+ ```
95
+
96
+ `convex` (`^1.42`) and `react` (`^18.3 || ^19`, only if you use the hooks) are
97
+ peer dependencies.
98
+
99
+ ---
100
+
101
+ ## Register the component
102
+
103
+ ```ts
104
+ // convex/convex.config.ts
105
+ import { defineApp } from "convex/server";
106
+ import epayco from "@pulgueta/epayco-convex/convex.config.js";
107
+
108
+ const app = defineApp();
109
+ app.use(epayco);
110
+
111
+ export default app;
112
+ ```
113
+
114
+ After this, run `npx convex dev` (or `codegen`) once so `components.epayco`
115
+ appears in your generated API.
116
+
117
+ ---
118
+
119
+ ## Environment variables
120
+
121
+ Set these on your Convex deployment (`npx convex env set …` or the dashboard):
122
+
123
+ | Variable | Required | Description |
124
+ | --- | --- | --- |
125
+ | `EPAYCO_PUBLIC_KEY` | Yes | ePayco `PUBLIC_KEY` (API public key) |
126
+ | `EPAYCO_PRIVATE_KEY` | Yes | ePayco `PRIVATE_KEY` (API private key) |
127
+ | `EPAYCO_P_CUST_ID_CLIENTE` | Webhooks | `P_CUST_ID_CLIENTE` — used to verify confirmation signatures |
128
+ | `EPAYCO_P_KEY` | Webhooks | `P_KEY` — used to verify confirmation signatures |
129
+ | `EPAYCO_TEST_MODE` | No | `"true"` to run against the ePayco sandbox |
130
+ | `EPAYCO_LANG` | No | `"ES"` (default) or `"EN"` |
131
+
132
+ These names map 1:1 to the fields in your ePayco dashboard. Credentials can also
133
+ be passed directly to the `EPayco` constructor (`publicKey`, `privateKey`,
134
+ `testMode`, `lang`), which takes precedence over the environment — handy for
135
+ multi-tenant setups where each tenant has its own keys.
136
+
137
+ > Secrets never reach the browser. The `EPayco` client reads them on the server
138
+ > and passes them to the component; your client code only ever calls your own
139
+ > Convex functions.
140
+
141
+ ---
142
+
143
+ ## Quickstart
144
+
145
+ A complete card charge — server action + reactive list + a button — in three
146
+ short files.
147
+
148
+ ### 1. Create the client and an action
149
+
150
+ ```ts
151
+ // convex/payments.ts
152
+ import { v } from "convex/values";
153
+ import { EPayco } from "@pulgueta/epayco-convex";
154
+ import { components } from "./_generated/api";
155
+ import { action, query } from "./_generated/server";
156
+
157
+ // Construct once. Credentials come from EPAYCO_PUBLIC_KEY / EPAYCO_PRIVATE_KEY.
158
+ const epayco = new EPayco(components.epayco, { testMode: true });
159
+
160
+ export const charge = action({
161
+ args: {
162
+ tokenCard: v.string(),
163
+ customerId: v.string(),
164
+ value: v.number(),
165
+ },
166
+ handler: async (ctx, args) => {
167
+ const userId = await requireUser(ctx); // your auth — see Security model
168
+ return await epayco.chargeCreditCard(ctx, {
169
+ userId,
170
+ chargeInfo: {
171
+ tokenCard: args.tokenCard,
172
+ customerId: args.customerId,
173
+ docType: "CC",
174
+ docNumber: "1234567890",
175
+ name: "Test",
176
+ lastName: "User",
177
+ email: "test@example.com",
178
+ bill: "ORDER-1001",
179
+ description: "Order #1001",
180
+ value: args.value,
181
+ tax: 0,
182
+ taxBase: 0,
183
+ },
184
+ });
185
+ },
186
+ });
187
+
188
+ // Reactive list — auto-updates when a webhook settles the payment.
189
+ export const myTransactions = query({
190
+ args: {},
191
+ handler: async (ctx) => {
192
+ const userId = await requireUser(ctx);
193
+ return await epayco.listTransactions(ctx, { userId });
194
+ },
195
+ });
196
+ ```
197
+
198
+ ### 2. Call it from React
199
+
200
+ ```tsx
201
+ import { useAction, useQuery } from "convex/react";
202
+ import { api } from "../convex/_generated/api";
203
+
204
+ function Checkout() {
205
+ const charge = useAction(api.payments.charge);
206
+ const txs = useQuery(api.payments.myTransactions) ?? [];
207
+
208
+ return (
209
+ <>
210
+ <button
211
+ onClick={() =>
212
+ charge({ tokenCard: "tok_xxx", customerId: "cust_xxx", value: 50000 })
213
+ }
214
+ >
215
+ Pay $50.000
216
+ </button>
217
+
218
+ <ul>
219
+ {txs.map((t) => (
220
+ <li key={t.epaycoRef}>
221
+ {t.epaycoRef} — {t.status} — ${t.amount.toLocaleString()}
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ </>
226
+ );
227
+ }
228
+ ```
229
+
230
+ That's the whole loop: the action charges and persists; the query is reactive,
231
+ so the list re-renders the instant the status changes (including later, when an
232
+ async confirmation arrives).
233
+
234
+ ---
235
+
236
+ ## Core concepts
237
+
238
+ **The `EPayco` class** is a thin, typed wrapper you construct once with the
239
+ component reference. Every method takes your Convex `ctx` plus typed args and
240
+ runs the relevant component action/query for you:
241
+
242
+ ```ts
243
+ const epayco = new EPayco(components.epayco, {
244
+ testMode: process.env.NODE_ENV !== "production",
245
+ });
246
+ ```
247
+
248
+ **`userId` scoping.** Mutating/charging methods take a `userId` that *you*
249
+ resolve from your auth on the server — it's the owner the transaction, token,
250
+ customer, or subscription is filed under. Local read methods (`listTransactions`,
251
+ `getActiveSubscription`, …) filter by it. The client never supplies it directly
252
+ (see [Security model](#security-model)).
253
+
254
+ **Two kinds of reads.** "From ePayco" methods (`getCharge`, `getPlan`, …) hit the
255
+ ePayco API live and must run in an **action**. "Local" methods (`listTransactions`,
256
+ `getLocalTokens`, `listLocalBanks`, …) read the component's synced tables and run
257
+ in a **query**, so they're reactive and cheap.
258
+
259
+ **Errors are thrown, not returned.** ePayco signals business failures inside a
260
+ `200` body (`{ error }`, `{ success: false }`, or a bare `{ message }` for
261
+ account-gated features). The component normalizes all of these into a
262
+ `ConvexError` so your `try/catch` and the client both get a clean message — see
263
+ [Error handling](#error-handling).
264
+
265
+ ---
266
+
267
+ ## Client-side wiring with React
268
+
269
+ The component ships hooks from `@pulgueta/epayco-convex/react`. The most useful
270
+ is `usePayment`, which wraps any payment action with `isLoading` / `error` /
271
+ `result` state so you don't re-implement it per button:
272
+
273
+ ```tsx
274
+ import { usePayment, useTransactions } from "@pulgueta/epayco-convex/react";
275
+ import { api } from "../convex/_generated/api";
276
+
277
+ function PayButton() {
278
+ const { execute, isLoading, error, result } = usePayment(api.payments.charge);
279
+
280
+ return (
281
+ <div>
282
+ <button
283
+ disabled={isLoading}
284
+ onClick={() =>
285
+ execute({ tokenCard: "tok_xxx", customerId: "cust_xxx", value: 50000 })
286
+ }
287
+ >
288
+ {isLoading ? "Processing…" : "Pay"}
289
+ </button>
290
+ {error && <p role="alert">{error.message}</p>}
291
+ {result && <p>Ref: {result.data?.ref_payco}</p>}
292
+ </div>
293
+ );
294
+ }
295
+
296
+ function History() {
297
+ // Pass any reactive query that returns transactions.
298
+ const txs = useTransactions(api.payments.myTransactions, {}) ?? [];
299
+ return <ul>{txs.map((t) => <li key={t.epaycoRef}>{t.status}</li>)}</ul>;
300
+ }
301
+ ```
302
+
303
+ The query hooks (`useTransactions`, `useTransaction`, `useSubscriptions`,
304
+ `useActiveSubscription`, `useCustomer`) are typed pass-throughs over Convex's
305
+ `useQuery` — they exist so payment reads read clearly at the call site. You can
306
+ always use plain `useQuery` / `useAction` instead; nothing here is required.
307
+
308
+ ---
309
+
310
+ ## Payment recipes
311
+
312
+ Each recipe is a server action you expose + the matching client call. Input field
313
+ names below come straight from the component's validators.
314
+
315
+ ### Credit cards
316
+
317
+ Card payments are three steps: **tokenize** the card → create a **customer** →
318
+ **charge** the token. Tokenization keeps raw card data out of your backend (the
319
+ component only ever stores the token, masked PAN, and franchise).
320
+
321
+ ```ts
322
+ // convex/cards.ts
323
+ import { v } from "convex/values";
324
+ import { EPayco } from "@pulgueta/epayco-convex";
325
+ import { components } from "./_generated/api";
326
+ import { action } from "./_generated/server";
327
+
328
+ const epayco = new EPayco(components.epayco, { testMode: true });
329
+
330
+ export const tokenize = action({
331
+ args: { cardNumber: v.string(), expYear: v.string(), expMonth: v.string(), cvc: v.string() },
332
+ handler: async (ctx, args) => {
333
+ const userId = await requireUser(ctx);
334
+ return await epayco.createToken(ctx, { userId, tokenInfo: args });
335
+ },
336
+ });
337
+
338
+ export const createCustomer = action({
339
+ args: { tokenCard: v.string(), name: v.string(), lastName: v.string(), email: v.string() },
340
+ handler: async (ctx, args) => {
341
+ const userId = await requireUser(ctx);
342
+ // ePayco requires last_name — always send it.
343
+ return await epayco.createCustomer(ctx, { userId, customerInfo: args });
344
+ },
345
+ });
346
+
347
+ export const charge = action({
348
+ args: { tokenCard: v.string(), customerId: v.string(), value: v.number() },
349
+ handler: async (ctx, args) => {
350
+ const userId = await requireUser(ctx);
351
+ return await epayco.chargeCreditCard(ctx, {
352
+ userId,
353
+ chargeInfo: {
354
+ tokenCard: args.tokenCard,
355
+ customerId: args.customerId,
356
+ docType: "CC",
357
+ docNumber: "1234567890",
358
+ name: "Test",
359
+ lastName: "User",
360
+ email: "test@example.com",
361
+ bill: "INV-1001",
362
+ description: "Order #1001",
363
+ value: args.value, // in COP, e.g. 50000 = $50.000
364
+ tax: 0, // IVA included in `value`
365
+ taxBase: 0, // taxable base
366
+ dues: 1, // installments
367
+ },
368
+ });
369
+ },
370
+ });
371
+ ```
372
+
373
+ > **Saved-card charges.** Once a customer exists, you can charge their default
374
+ > card without a fresh token by passing `useDefaultCardCustomer: true` in
375
+ > `chargeInfo`. Manage cards with `epayco.addNewToken`, `epayco.addDefaultCard`,
376
+ > and `epayco.deleteCustomerCard`.
377
+
378
+ ### PSE bank transfer
379
+
380
+ PSE needs the live list of banks first (cached locally), then a transaction that
381
+ returns a redirect URL you send the buyer to.
382
+
383
+ ```ts
384
+ export const banks = query({
385
+ args: {},
386
+ handler: (ctx) => epayco.listLocalBanks(ctx), // reactive, from the synced table
387
+ });
388
+
389
+ export const refreshBanks = action({
390
+ args: {},
391
+ handler: (ctx) => epayco.getBanks(ctx), // fetch + cache from ePayco
392
+ });
393
+
394
+ export const payWithPse = action({
395
+ args: { bank: v.string(), value: v.number() },
396
+ handler: async (ctx, args) => {
397
+ const userId = await requireUser(ctx);
398
+ const result = await epayco.createPseTransaction(ctx, {
399
+ userId,
400
+ pseInfo: {
401
+ bank: args.bank,
402
+ typePerson: "0", // "0" natural person, "1" company
403
+ docType: "CC",
404
+ docNumber: "1234567890",
405
+ name: "Test",
406
+ lastName: "User",
407
+ email: "test@example.com",
408
+ cellPhone: "3001234567",
409
+ bill: "INV-2001",
410
+ description: "Order #2001",
411
+ value: args.value,
412
+ tax: 0,
413
+ taxBase: 0,
414
+ urlResponse: "https://your-app.com/pse/return",
415
+ },
416
+ });
417
+ // result.data.urlbanco — redirect the buyer here.
418
+ return result;
419
+ },
420
+ });
421
+ ```
422
+
423
+ When the buyer finishes at their bank, ePayco calls your confirmation webhook and
424
+ the transaction row flips to `approved`/`rejected` automatically.
425
+
426
+ ### Cash vouchers
427
+
428
+ Generate a voucher/PIN the buyer pays at a physical point. `provider` picks the
429
+ network.
430
+
431
+ ```ts
432
+ export const payWithCash = action({
433
+ args: {
434
+ provider: v.union(
435
+ v.literal("efecty"), v.literal("baloto"), v.literal("gana"),
436
+ v.literal("redservi"), v.literal("puntored"), v.literal("sured"),
437
+ ),
438
+ value: v.number(),
439
+ },
440
+ handler: async (ctx, args) => {
441
+ const userId = await requireUser(ctx);
442
+ return await epayco.createCashPayment(ctx, {
443
+ userId,
444
+ provider: args.provider,
445
+ cashInfo: {
446
+ docType: "CC",
447
+ docNumber: "1234567890",
448
+ name: "Test",
449
+ lastName: "User",
450
+ email: "test@example.com",
451
+ cellPhone: "3001234567",
452
+ bill: "INV-3001",
453
+ description: "Order #3001",
454
+ value: args.value,
455
+ tax: 0,
456
+ taxBase: 0,
457
+ endDate: "2026-12-31", // voucher expiry (optional)
458
+ },
459
+ });
460
+ // result.data.ref_payco + PIN/barcode → show to the buyer.
461
+ },
462
+ });
463
+ ```
464
+
465
+ ### Daviplata
466
+
467
+ Two steps: start the payment (returns a `ref_payco` + `id_session_token`), then
468
+ confirm with the OTP the buyer receives. The session token is **redacted** from
469
+ the persisted transaction (it's a short-lived secret) but returned to your action
470
+ so you can pass it to the confirm step.
471
+
472
+ ```ts
473
+ export const startDaviplata = action({
474
+ args: { value: v.number(), phone: v.string() },
475
+ handler: async (ctx, args) => {
476
+ const userId = await requireUser(ctx);
477
+ return await epayco.createDaviplataPayment(ctx, {
478
+ userId,
479
+ daviplataInfo: {
480
+ docType: "CC",
481
+ docNumber: "1234567890",
482
+ name: "Test",
483
+ lastName: "User",
484
+ email: "test@example.com",
485
+ phone: args.phone,
486
+ description: "Order #4001",
487
+ value: args.value,
488
+ tax: 0,
489
+ taxBase: 0,
490
+ },
491
+ });
492
+ // → { data: { ref_payco, id_session_token } }
493
+ },
494
+ });
495
+
496
+ export const confirmDaviplata = action({
497
+ args: { refPayco: v.string(), idSessionToken: v.string(), otp: v.string() },
498
+ handler: async (ctx, args) => {
499
+ await requireUser(ctx);
500
+ return await epayco.confirmDaviplataPayment(ctx, args);
501
+ },
502
+ });
503
+ ```
504
+
505
+ OTP confirmation is rate-limited per `ref_payco` to blunt brute-force guessing.
506
+
507
+ ### SafetyPay
508
+
509
+ ```ts
510
+ export const payWithSafetyPay = action({
511
+ args: { value: v.number() },
512
+ handler: async (ctx, args) => {
513
+ const userId = await requireUser(ctx);
514
+ return await epayco.createSafetyPayPayment(ctx, {
515
+ userId,
516
+ safetypayInfo: {
517
+ cash: "1", // "1" cash, "2" online bank
518
+ docType: "CC",
519
+ docNumber: "1234567890",
520
+ name: "Test",
521
+ lastName: "User",
522
+ email: "test@example.com",
523
+ phone: "3001234567",
524
+ description: "Order #5001",
525
+ value: args.value,
526
+ tax: 0,
527
+ taxBase: 0,
528
+ },
529
+ });
530
+ },
531
+ });
532
+ ```
533
+
534
+ ### Plans & subscriptions
535
+
536
+ Create a billing plan once, then subscribe a customer's saved card to it.
537
+
538
+ ```ts
539
+ export const createPlan = action({
540
+ args: {},
541
+ handler: async (ctx) => {
542
+ await requireAdmin(ctx);
543
+ return await epayco.createPlan(ctx, {
544
+ planInfo: {
545
+ idPlan: "pro-monthly",
546
+ name: "Pro Monthly",
547
+ description: "Pro plan billed monthly",
548
+ amount: 49900,
549
+ currency: "COP",
550
+ interval: "month",
551
+ intervalCount: 1,
552
+ trialDays: 0,
553
+ },
554
+ });
555
+ },
556
+ });
557
+
558
+ export const subscribe = action({
559
+ args: { customer: v.string(), tokenCard: v.string() },
560
+ handler: async (ctx, args) => {
561
+ const userId = await requireUser(ctx);
562
+ return await epayco.createSubscription(ctx, {
563
+ userId,
564
+ subscriptionInfo: {
565
+ idPlan: "pro-monthly",
566
+ customer: args.customer, // ePayco customer id
567
+ tokenCard: args.tokenCard,
568
+ docType: "CC",
569
+ docNumber: "1234567890",
570
+ },
571
+ });
572
+ },
573
+ });
574
+
575
+ export const cancel = action({
576
+ args: { epaycoSubscriptionId: v.string() },
577
+ handler: async (ctx, args) => {
578
+ await requireUser(ctx);
579
+ return await epayco.cancelSubscription(ctx, args);
580
+ },
581
+ });
582
+
583
+ // Reactive: the current active subscription for the signed-in user.
584
+ export const activeSubscription = query({
585
+ args: {},
586
+ handler: async (ctx) => {
587
+ const userId = await requireUser(ctx);
588
+ return await epayco.getActiveSubscription(ctx, { userId });
589
+ },
590
+ });
591
+ ```
592
+
593
+ To bill a plan immediately (one-off recurring charge), use
594
+ `epayco.chargeSubscription(ctx, { userId, idPlan, customer, tokenCard, docType, docNumber })`
595
+ — it's rate-limited and the resulting charge is persisted to your transaction
596
+ history.
597
+
598
+ > Recurring billing is an **account-gated** ePayco feature. See
599
+ > [Sandbox & account-gated features](#sandbox--account-gated-features).
600
+
601
+ ### Split payments
602
+
603
+ For marketplaces, disperse a single charge across receivers by adding `split` to
604
+ `chargeInfo` / `pseInfo` / `cashInfo`:
605
+
606
+ ```ts
607
+ chargeInfo: {
608
+ /* …normal fields… */
609
+ split: {
610
+ splitType: "02",
611
+ splitReceivers: [
612
+ { id: "1", total: "58000", iva: "8000", base_iva: "50000", fee: "10" },
613
+ ],
614
+ },
615
+ }
616
+ ```
617
+
618
+ The receivers are also stored (in numeric form) on the transaction row as
619
+ `splitReceivers` for your records.
620
+
621
+ ---
622
+
623
+ ## Webhooks & payment confirmation
624
+
625
+ For every async method (PSE, cash, Daviplata, SafetyPay) ePayco calls your
626
+ **confirmation URL** when the payment settles. Register the routes on your HTTP
627
+ router:
628
+
629
+ ```ts
630
+ // convex/http.ts
631
+ import { httpRouter } from "convex/server";
632
+ import { registerRoutes } from "@pulgueta/epayco-convex";
633
+ import { components } from "./_generated/api";
634
+
635
+ const http = httpRouter();
636
+
637
+ registerRoutes(http, components.epayco, {
638
+ pathPrefix: "/epayco", // default
639
+ // custIdCliente / pKey default to EPAYCO_P_CUST_ID_CLIENTE / EPAYCO_P_KEY
640
+ });
641
+
642
+ export default http;
643
+ ```
644
+
645
+ This wires three routes:
646
+
647
+ | Route | Method | Purpose |
648
+ | --- | --- | --- |
649
+ | `/epayco/confirmation` | `POST` / `GET` | ePayco → your app. Verifies the SHA-256 signature, then updates the transaction. |
650
+ | `/epayco/response` | `GET` | The buyer's browser is redirected here. Returns a **minimal**, non-PII status payload. |
651
+
652
+ Point your ePayco dashboard's confirmation URL at:
653
+
654
+ ```
655
+ https://YOUR_DEPLOYMENT.convex.site/epayco/confirmation
656
+ ```
657
+
658
+ The handler is hardened:
659
+
660
+ - The signature is verified **before** anything is persisted, so an
661
+ unauthenticated caller can't write arbitrary payloads into your tables.
662
+ - Events are stored idempotently (one row per `ref_payco`), so retries and races
663
+ don't accumulate duplicates and an already-processed confirmation is a no-op.
664
+ - If a confirmation arrives **before** the local transaction exists, the event is
665
+ left `pending` (with the verified payload stored) for reconciliation instead of
666
+ being silently dropped.
667
+ - The public `/response` endpoint returns only `{ ref_payco, status, paymentMethod,
668
+ amount, currency }` — never the customer email, raw response, or split details.
669
+
670
+ ---
671
+
672
+ ## Reactive reads & `exposeApi`
673
+
674
+ Two ways to read payment state in your client.
675
+
676
+ **Option A — your own query** (full control). Resolve the user from auth and call
677
+ the local read method:
678
+
679
+ ```ts
680
+ export const myTransactions = query({
681
+ args: { status: v.optional(v.string()) },
682
+ handler: async (ctx, args) => {
683
+ const userId = await requireUser(ctx);
684
+ return await epayco.listTransactions(ctx, { userId, status: args.status });
685
+ },
686
+ });
687
+ ```
688
+
689
+ **Option B — `exposeApi`** (zero boilerplate). It generates a set of auth-gated
690
+ queries you re-export directly. You provide one `auth` callback that returns the
691
+ caller's id; the queries are **always scoped to that id** — there is no
692
+ client-supplied `userId`, so a signed-in user can never read another user's data.
693
+
694
+ ```ts
695
+ // convex/payments.ts
696
+ import { exposeApi } from "@pulgueta/epayco-convex";
697
+ import { components } from "./_generated/api";
698
+
699
+ export const {
700
+ listTransactions,
701
+ getTransaction, // returns null unless the row belongs to the caller
702
+ getCustomer,
703
+ listSubscriptions,
704
+ getActiveSubscription,
705
+ } = exposeApi(components.epayco, {
706
+ auth: async (ctx, _operation) => {
707
+ const identity = await ctx.auth.getUserIdentity();
708
+ if (!identity) throw new Error("Unauthorized");
709
+ return identity.subject; // this is the only scope these queries will use
710
+ },
711
+ });
712
+ ```
713
+
714
+ ```tsx
715
+ // client
716
+ const txs = useQuery(api.payments.listTransactions, {}) ?? [];
717
+ const sub = useQuery(api.payments.getActiveSubscription, {});
718
+ ```
719
+
720
+ ---
721
+
722
+ ## Security model
723
+
724
+ - **Secrets stay server-side.** API keys are read from the environment (or the
725
+ constructor) and never sent to the browser. Clients only call your Convex
726
+ functions.
727
+ - **Owner scoping is server-resolved.** You pass `userId` from your auth on the
728
+ server. The `exposeApi` reactive queries take **no** client `userId` — scope is
729
+ always the authenticated identity, and `getTransaction` returns `null` unless
730
+ the row belongs to the caller. This closes object-level authorization holes.
731
+ - **Card data is never stored.** Tokenization keeps raw PANs out of your backend;
732
+ only the token id, masked PAN, and franchise are persisted.
733
+ - **Confirmation webhooks are verified** with a constant-time SHA-256 check
734
+ before any data is written, and processed idempotently.
735
+ - **Secrets are redacted** before persistence (e.g. the Daviplata
736
+ `id_session_token`).
737
+ - **Rate limiting** is built in on every money-moving and abuse-prone path (see
738
+ [Rate limits](#rate-limits)).
739
+
740
+ Always gate your payment actions behind your own auth. A minimal helper:
741
+
742
+ ```ts
743
+ import type { Auth } from "convex/server";
744
+
745
+ async function requireUser(ctx: { auth: Auth }) {
746
+ const identity = await ctx.auth.getUserIdentity();
747
+ if (!identity) throw new Error("Unauthorized");
748
+ return identity.subject;
749
+ }
750
+ ```
751
+
752
+ ---
753
+
754
+ ## Integration patterns by app type
755
+
756
+ **SaaS (recurring revenue).** Create plans up front; on signup, tokenize the
757
+ card → create customer → `createSubscription`. Render `getActiveSubscription` in
758
+ your billing page and gate features on `subscription.status === "active"`. Use the
759
+ confirmation webhook to react to renewals/failures.
760
+
761
+ **E-commerce (one-time checkout).** Offer cards + PSE + cash. Cards settle
762
+ synchronously (`chargeCreditCard` returns the final status); PSE/cash return a
763
+ redirect URL or voucher and settle via webhook. Subscribe the order page to
764
+ `getTransaction(ref)` so it flips to "Paid" on its own.
765
+
766
+ **Marketplace (split settlement).** Use the `split` field on the charge/PSE/cash
767
+ call to disperse funds to sellers in the same transaction; the receivers are
768
+ stored on the transaction for reconciliation.
769
+
770
+ **Donations / POS / kiosks.** Cash vouchers (`createCashPayment`) and SafetyPay
771
+ let users pay offline; show the PIN/voucher and let the reactive transaction list
772
+ update when it's paid. Daviplata covers wallet users with an in-app OTP.
773
+
774
+ In every case the pattern is the same: **call one action, subscribe one query.**
775
+ The component keeps the synced tables current, your UI stays reactive, and
776
+ webhooks reconcile async settlement without polling.
777
+
778
+ ---
779
+
780
+ ## Status vocabulary
781
+
782
+ ePayco's numeric/textual states are normalized to one canonical set on every
783
+ transaction:
784
+
785
+ | Canonical | Meaning | ePayco source |
786
+ | --- | --- | --- |
787
+ | `pending` | Awaiting payment/confirmation | cod 3, 7; "pendiente" |
788
+ | `approved` | Paid | cod 1; "aceptada" |
789
+ | `rejected` | Declined / cancelled | cod 2, 11, 12; "rechazada", "cancelada" |
790
+ | `failed` | Failed / abandoned | cod 4, 10; "fallida", "abandonada" |
791
+ | `expired` | Voucher/timeout expired | cod 9; "expirada" |
792
+ | `reversed` | Refunded / reversed | cod 6; "reversada" |
793
+
794
+ ---
795
+
796
+ ## Error handling
797
+
798
+ Business failures surface as a `ConvexError` whose `data` is
799
+ `{ code: "EPAYCO_API_ERROR", message, raw }`. Extract the message on the client:
800
+
801
+ ```ts
802
+ function errMsg(err: unknown): string {
803
+ if (err && typeof err === "object" && "data" in err) {
804
+ const data = (err as { data?: { message?: unknown } }).data;
805
+ if (data && typeof data.message === "string") return data.message;
806
+ }
807
+ return err instanceof Error ? err.message : "Something went wrong";
808
+ }
809
+ ```
810
+
811
+ ```tsx
812
+ try {
813
+ await charge({ /* … */ });
814
+ } catch (err) {
815
+ setError(errMsg(err)); // e.g. "La tarjeta fue rechazada"
816
+ }
817
+ ```
818
+
819
+ Rate-limit rejections also throw, so the same `try/catch` covers them.
820
+
821
+ ---
822
+
823
+ ## API reference
824
+
825
+ `new EPayco(components.epayco, options?)` — `options`: `publicKey`,
826
+ `privateKey`, `testMode`, `lang`.
827
+
828
+ ### Payment methods (actions)
829
+
830
+ | Method | Description |
831
+ | --- | --- |
832
+ | `chargeCreditCard(ctx, { userId, chargeInfo })` | Charge a tokenized card |
833
+ | `getCharge(ctx, { epaycoRef })` | Look up a charge from ePayco |
834
+ | `createPseTransaction(ctx, { userId, pseInfo })` | Start a PSE debit |
835
+ | `getPseTransaction(ctx, { ticketId })` | PSE status from ePayco |
836
+ | `createCashPayment(ctx, { userId, provider, cashInfo })` | Cash voucher |
837
+ | `getCashPayment(ctx, { epaycoRef })` | Cash status from ePayco |
838
+ | `createDaviplataPayment(ctx, { userId, daviplataInfo })` | Start Daviplata |
839
+ | `confirmDaviplataPayment(ctx, { refPayco, idSessionToken, otp })` | Confirm with OTP |
840
+ | `createSafetyPayPayment(ctx, { userId, safetypayInfo })` | SafetyPay |
841
+
842
+ ### Tokens & customers (actions)
843
+
844
+ | Method | Description |
845
+ | --- | --- |
846
+ | `createToken(ctx, { userId, tokenInfo })` | Tokenize a card |
847
+ | `createCustomer(ctx, { userId, customerInfo })` | Create an ePayco customer |
848
+ | `getCustomer(ctx, { epaycoCustomerId, userId })` | Customer from ePayco |
849
+ | `listCustomers(ctx, { page?, perPage? })` | List customers from ePayco |
850
+ | `updateCustomer(ctx, { userId, epaycoCustomerId, … })` | Update a customer |
851
+ | `addNewToken` / `addDefaultCard` / `deleteCustomerCard` | Manage saved cards |
852
+
853
+ ### Plans & subscriptions (actions)
854
+
855
+ | Method | Description |
856
+ | --- | --- |
857
+ | `createPlan(ctx, { planInfo })` | Create a recurring plan |
858
+ | `getPlan` / `listPlans` / `updatePlan` / `deletePlan` | Manage plans |
859
+ | `createSubscription(ctx, { userId, subscriptionInfo })` | Subscribe a customer |
860
+ | `cancelSubscription(ctx, { epaycoSubscriptionId })` | Cancel |
861
+ | `chargeSubscription(ctx, { userId, idPlan, customer, tokenCard, docType, docNumber })` | One-off recurring charge |
862
+ | `getSubscription` / `listSubscriptionsFromEpayco` | Read from ePayco |
863
+
864
+ ### Reactive local reads (queries)
865
+
866
+ | Method | Description |
867
+ | --- | --- |
868
+ | `listTransactions(ctx, { userId, status?, paymentMethod?, limit? })` | Synced transactions |
869
+ | `getTransaction(ctx, { epaycoRef })` | One transaction (local) |
870
+ | `getLocalTokens(ctx, { userId })` | Active saved tokens |
871
+ | `getLocalCustomer(ctx, { userId })` | Synced customer |
872
+ | `getActiveSubscription(ctx, { userId })` | Current active subscription |
873
+ | `listSubscriptions(ctx, { userId })` | Non-cancelled subscriptions |
874
+ | `listLocalPlans(ctx, { status? })` / `getLocalPlan` | Synced plans |
875
+ | `listLocalBanks(ctx)` | Cached PSE bank list |
876
+ | `getBanks(ctx)` *(action)* | Refresh + cache PSE banks from ePayco |
877
+
878
+ ### Helpers
879
+
880
+ | Export | Description |
881
+ | --- | --- |
882
+ | `exposeApi(component, { auth })` | Auth-gated reactive queries (no client `userId`) |
883
+ | `registerRoutes(http, component, { pathPrefix?, custIdCliente?, pKey? })` | Webhook + response routes |
884
+ | `@pulgueta/epayco-convex/react` | `usePayment`, `useTransactions`, `useTransaction`, `useSubscriptions`, `useActiveSubscription`, `useCustomer` |
885
+
886
+ ---
887
+
888
+ ## Database tables
889
+
890
+ The component manages 7 isolated tables in its own namespace:
891
+
892
+ - `customers` — synced ePayco customers
893
+ - `tokens` — tokenized card references (never raw card data)
894
+ - `transactions` — all payments across every method
895
+ - `plans` — recurring billing plans
896
+ - `subscriptions` — customer↔plan associations
897
+ - `banks` — cached PSE bank list
898
+ - `webhookEvents` — confirmation audit trail with idempotency
899
+
900
+ ---
901
+
902
+ ## Rate limits
903
+
904
+ Built-in, via `@convex-dev/rate-limiter`:
905
+
906
+ | Operation | Limit | Key |
907
+ | --- | --- | --- |
908
+ | Customer / subscription creation | 5/min | `userId` |
909
+ | Token / card / PSE / cash / SafetyPay / Daviplata creation | 10/min | `userId` |
910
+ | Daviplata OTP confirmation | 5/min | `ref_payco` |
911
+ | Immediate subscription charge | 5/min | `userId` |
912
+ | Webhook processing | 200/min | `ref_payco` |
913
+
914
+ ---
915
+
916
+ ## Sandbox & account-gated features
917
+
918
+ - Set `EPAYCO_TEST_MODE=true` (or `testMode: true`) to use the ePayco sandbox.
919
+ - **Recurring billing** (`createPlan` / `createSubscription`) requires the
920
+ recurring-payments feature to be enabled on your ePayco merchant account.
921
+ Until then ePayco rejects the request, which the component surfaces as a
922
+ `ConvexError` — not a silent success.
923
+ - The ePayco **PSE sandbox** is disabled server-side by ePayco, so
924
+ `createPseTransaction` only completes in production.
925
+
926
+ These are ePayco-side gates, not component limitations; they surface cleanly as
927
+ errors so you can detect and message them.
928
+
929
+ ---
930
+
931
+ ## Testing
932
+
933
+ ```bash
934
+ pnpm test
935
+ ```
936
+
937
+ Tests use `convex-test` for isolated component testing, plus a crypto-parity
938
+ suite that pins the AES-128-CBC output byte-for-byte against the official SDK's
939
+ `crypto-js` vectors. See `src/component/*.test.ts`.
940
+
941
+ ---
942
+
943
+ ## License
944
+
945
+ Apache-2.0