medusa-payment-kadima 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/.medusa/server/src/chunk-A4QO3PSZ.js +33 -0
- package/.medusa/server/src/chunk-MKSGS2DZ.js +454 -0
- package/.medusa/server/src/chunk-R62JQB26.js +197 -0
- package/.medusa/server/src/index.d.ts +44 -0
- package/.medusa/server/src/index.js +18 -0
- package/.medusa/server/src/providers/kadima-ach.d.ts +141 -0
- package/.medusa/server/src/providers/kadima-ach.js +7 -0
- package/.medusa/server/src/providers/kadima-card.d.ts +249 -0
- package/.medusa/server/src/providers/kadima-card.js +7 -0
- package/.medusa/server/src/types-BUuMxNOL.d.ts +37 -0
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/package.json +62 -0
- package/src/index.ts +43 -0
- package/src/lib/errors.ts +46 -0
- package/src/lib/kadima-ach-client.ts +166 -0
- package/src/lib/kadima-card-client.ts +259 -0
- package/src/lib/kadima-vault-client.ts +163 -0
- package/src/lib/webhook.test.ts +56 -0
- package/src/lib/webhook.ts +41 -0
- package/src/providers/kadima-ach.ts +178 -0
- package/src/providers/kadima-card.ts +270 -0
- package/src/storefront/hosted-fields-example.md +82 -0
- package/src/types.ts +61 -0
- package/storefront/KadimaAchForm.tsx +88 -0
- package/storefront/KadimaHostedFields.tsx +119 -0
- package/storefront/README.md +78 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/lib/webhook.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
function computeSignature(secret, envelope) {
|
|
4
|
+
const payload = `${secret}${envelope.id}${envelope.module}${envelope.action}${envelope.date}`;
|
|
5
|
+
return createHash("sha512").update(payload).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
function verifySignature(secret, envelope, headerSignature) {
|
|
8
|
+
if (!headerSignature) return false;
|
|
9
|
+
const expected = computeSignature(secret, envelope);
|
|
10
|
+
if (expected.length !== headerSignature.length) return false;
|
|
11
|
+
let diff = 0;
|
|
12
|
+
for (let i = 0; i < expected.length; i++) {
|
|
13
|
+
diff |= expected.charCodeAt(i) ^ headerSignature.charCodeAt(i);
|
|
14
|
+
}
|
|
15
|
+
return diff === 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/types.ts
|
|
19
|
+
var CARD_HOSTS = {
|
|
20
|
+
prod: "https://gateway.kadimadashboard.com",
|
|
21
|
+
sandbox: "https://sandbox-gateway.kadimadashboard.com"
|
|
22
|
+
};
|
|
23
|
+
var DASHBOARD_HOSTS = {
|
|
24
|
+
prod: "https://kadimadashboard.com",
|
|
25
|
+
// CONFIRMED: dashboard/ACH sandbox host (distinct from the card gateway sandbox).
|
|
26
|
+
sandbox: "https://sandbox.kadimadashboard.com"
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
CARD_HOSTS,
|
|
31
|
+
DASHBOARD_HOSTS,
|
|
32
|
+
verifySignature
|
|
33
|
+
};
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CARD_HOSTS,
|
|
3
|
+
DASHBOARD_HOSTS,
|
|
4
|
+
verifySignature
|
|
5
|
+
} from "./chunk-A4QO3PSZ.js";
|
|
6
|
+
|
|
7
|
+
// src/providers/kadima-card.ts
|
|
8
|
+
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
|
|
9
|
+
|
|
10
|
+
// src/lib/errors.ts
|
|
11
|
+
var KadimaError = class extends Error {
|
|
12
|
+
constructor(message, status, reason, raw) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.raw = raw;
|
|
17
|
+
this.name = "KadimaError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var RETRYABLE = /* @__PURE__ */ new Set([
|
|
21
|
+
"Time out",
|
|
22
|
+
"System Error",
|
|
23
|
+
"Error on Host",
|
|
24
|
+
"Host connectivity failed",
|
|
25
|
+
"Issuer or Switch inoperative",
|
|
26
|
+
"Functionality currently not available"
|
|
27
|
+
]);
|
|
28
|
+
function isRetryable(reason) {
|
|
29
|
+
return reason ? RETRYABLE.has(reason) : false;
|
|
30
|
+
}
|
|
31
|
+
function assertApproved(resp) {
|
|
32
|
+
const s = resp.status?.status;
|
|
33
|
+
if (s !== "Approved") {
|
|
34
|
+
throw new KadimaError(
|
|
35
|
+
`Kadima transaction ${s ?? "Error"}: ${resp.status?.reason ?? "unknown"}`,
|
|
36
|
+
s ?? "Error",
|
|
37
|
+
resp.status?.reason,
|
|
38
|
+
resp
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/lib/kadima-card-client.ts
|
|
44
|
+
var KadimaCardClient = class {
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
this.gatewayBase = opts.sandbox ? CARD_HOSTS.sandbox : CARD_HOSTS.prod;
|
|
48
|
+
this.dashboardBase = opts.sandbox ? DASHBOARD_HOSTS.sandbox : DASHBOARD_HOSTS.prod;
|
|
49
|
+
}
|
|
50
|
+
// --- Hosted Fields: mint a single-use token for the storefront ----------
|
|
51
|
+
// Request: { expiration<=30, terminal, domain, saveCard, "3ds" }
|
|
52
|
+
async createHostedFieldsToken(input) {
|
|
53
|
+
return this.request(
|
|
54
|
+
`${this.dashboardBase}/api/hosted-fields/token`,
|
|
55
|
+
{
|
|
56
|
+
terminal: this.opts.terminalId,
|
|
57
|
+
domain: input.domain,
|
|
58
|
+
saveCard: input.saveCard ?? "disabled",
|
|
59
|
+
"3ds": input.threeds ?? false,
|
|
60
|
+
expiration: Math.min(input.expiration ?? 30, 30)
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Fetch the saved card token after a Hosted Fields payment (if saveCard allowed).
|
|
65
|
+
async getHostedFieldsCardToken(accessToken) {
|
|
66
|
+
return this.request(`${this.dashboardBase}/api/hosted-fields/card-token`, {
|
|
67
|
+
accessToken
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// --- Server-to-server charges (stored token / recurring / MOTO) ----------
|
|
71
|
+
async auth(input) {
|
|
72
|
+
const resp = await this.request(
|
|
73
|
+
`${this.gatewayBase}/payment/auth`,
|
|
74
|
+
this.buildChargeBody(input)
|
|
75
|
+
);
|
|
76
|
+
assertApproved(resp);
|
|
77
|
+
return resp;
|
|
78
|
+
}
|
|
79
|
+
async sale(input) {
|
|
80
|
+
const resp = await this.request(
|
|
81
|
+
`${this.gatewayBase}/payment/sale`,
|
|
82
|
+
this.buildChargeBody(input)
|
|
83
|
+
);
|
|
84
|
+
assertApproved(resp);
|
|
85
|
+
return resp;
|
|
86
|
+
}
|
|
87
|
+
// POST /payment/<id>/capture body: { terminal, amount?, partial? }
|
|
88
|
+
async capture(id, amount, partial) {
|
|
89
|
+
const resp = await this.request(
|
|
90
|
+
`${this.gatewayBase}/payment/${id}/capture`,
|
|
91
|
+
{
|
|
92
|
+
terminal: { id: this.opts.terminalId },
|
|
93
|
+
...amount != null ? { amount } : {},
|
|
94
|
+
...partial ? { partial } : {}
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
assertApproved(resp);
|
|
98
|
+
return resp;
|
|
99
|
+
}
|
|
100
|
+
// POST /payment/<id>/refund body: { terminal, amount? }
|
|
101
|
+
async refund(id, amount) {
|
|
102
|
+
const resp = await this.request(
|
|
103
|
+
`${this.gatewayBase}/payment/${id}/refund`,
|
|
104
|
+
{
|
|
105
|
+
terminal: { id: this.opts.terminalId },
|
|
106
|
+
...amount != null ? { amount } : {}
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
assertApproved(resp);
|
|
110
|
+
return resp;
|
|
111
|
+
}
|
|
112
|
+
/** Zero-dollar card validity check (no funds held). */
|
|
113
|
+
async cardAuthentication(input) {
|
|
114
|
+
return this.request(`${this.gatewayBase}/payment/card-authentication`, {
|
|
115
|
+
...this.buildChargeBody(input),
|
|
116
|
+
amount: 0
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Tokenize a card without an auth or sale. */
|
|
120
|
+
async generateToken(input) {
|
|
121
|
+
return this.request(`${this.gatewayBase}/payment/generate-token`, {
|
|
122
|
+
terminal: { id: this.opts.terminalId },
|
|
123
|
+
source: input.source ?? "Internet",
|
|
124
|
+
card: this.buildCard(input)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
buildChargeBody(input) {
|
|
128
|
+
return {
|
|
129
|
+
terminal: { id: this.opts.terminalId },
|
|
130
|
+
amount: input.amount,
|
|
131
|
+
source: input.source ?? "Internet",
|
|
132
|
+
level: input.level ?? 1,
|
|
133
|
+
card: this.buildCard(input),
|
|
134
|
+
// externalId is the merchant-supplied correlation key (<=64 chars, unique).
|
|
135
|
+
...input.externalId ? { externalId: input.externalId } : {},
|
|
136
|
+
...input.contact ? { contact: input.contact } : {},
|
|
137
|
+
...input.order ? { order: input.order } : {},
|
|
138
|
+
...input.isRecurring ? { isRecurring: "Yes" } : {}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
buildCard(input) {
|
|
142
|
+
if (input.cardToken) {
|
|
143
|
+
return {
|
|
144
|
+
token: input.cardToken,
|
|
145
|
+
...input.store ? { store: "Yes" } : {},
|
|
146
|
+
...input.networkTransactionId ? { networkTransactionId: input.networkTransactionId } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
name: input.card?.name,
|
|
151
|
+
number: input.card?.number,
|
|
152
|
+
exp: input.card?.exp,
|
|
153
|
+
cvv: input.card?.cvv,
|
|
154
|
+
...input.card?.address ? { address: input.card.address } : {},
|
|
155
|
+
...input.save ? { save: "Yes" } : {},
|
|
156
|
+
...input.store ? { store: "Yes" } : {}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// --- transport: bearer auth + retry on 5xx/network/retryable declines ----
|
|
160
|
+
async request(url, body, attempt = 0) {
|
|
161
|
+
let resp;
|
|
162
|
+
try {
|
|
163
|
+
resp = await fetch(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: {
|
|
166
|
+
Authorization: `Bearer ${this.opts.apiToken}`,
|
|
167
|
+
"Content-Type": "application/json"
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify(body)
|
|
170
|
+
});
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (attempt < 2) return this.request(url, body, attempt + 1);
|
|
173
|
+
throw new KadimaError(`Network error calling Kadima: ${String(e)}`, "Error");
|
|
174
|
+
}
|
|
175
|
+
const text = await resp.text();
|
|
176
|
+
const json = text ? safeJson(text) : {};
|
|
177
|
+
if (resp.status >= 500 && attempt < 2) {
|
|
178
|
+
await delay(2 ** attempt * 500);
|
|
179
|
+
return this.request(url, body, attempt + 1);
|
|
180
|
+
}
|
|
181
|
+
if (!resp.ok) {
|
|
182
|
+
const msg = json?.message ?? text;
|
|
183
|
+
throw new KadimaError(`Kadima HTTP ${resp.status}: ${msg}`, "Error", void 0, json);
|
|
184
|
+
}
|
|
185
|
+
const reason = json?.status?.reason;
|
|
186
|
+
if (isRetryable(reason) && attempt < 2) {
|
|
187
|
+
await delay(2 ** attempt * 500);
|
|
188
|
+
return this.request(url, body, attempt + 1);
|
|
189
|
+
}
|
|
190
|
+
return json;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function safeJson(t) {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(t);
|
|
196
|
+
} catch {
|
|
197
|
+
return { raw: t };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
201
|
+
|
|
202
|
+
// src/lib/kadima-vault-client.ts
|
|
203
|
+
var KadimaVaultClient = class {
|
|
204
|
+
constructor(opts) {
|
|
205
|
+
this.opts = opts;
|
|
206
|
+
const host = opts.sandbox ? DASHBOARD_HOSTS.sandbox : DASHBOARD_HOSTS.prod;
|
|
207
|
+
this.base = `${host}/api/customer-vault`;
|
|
208
|
+
}
|
|
209
|
+
// Customer requires dba.id. Returns the customer record incl. a vault `token`.
|
|
210
|
+
async createCustomer(input) {
|
|
211
|
+
if (this.opts.dbaId == null) {
|
|
212
|
+
throw new Error("KadimaVaultClient: dbaId is required for CustomerVault");
|
|
213
|
+
}
|
|
214
|
+
return this.request("POST", this.base, {
|
|
215
|
+
dba: { id: this.opts.dbaId },
|
|
216
|
+
...input.firstName ? { firstName: input.firstName } : {},
|
|
217
|
+
...input.lastName ? { lastName: input.lastName } : {},
|
|
218
|
+
...input.company ? { company: input.company } : {},
|
|
219
|
+
...input.email ? { email: input.email } : {},
|
|
220
|
+
...input.phone ? { phone: input.phone } : {},
|
|
221
|
+
...input.identificator ? { identificator: input.identificator } : {},
|
|
222
|
+
...input.description ? { description: input.description } : {}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async getCustomer(id) {
|
|
226
|
+
return this.request("GET", `${this.base}/${id}`);
|
|
227
|
+
}
|
|
228
|
+
async updateCustomer(id, patch) {
|
|
229
|
+
return this.request("PUT", `${this.base}/${id}`, patch);
|
|
230
|
+
}
|
|
231
|
+
async deleteCustomer(id) {
|
|
232
|
+
return this.request("DELETE", `${this.base}/${id}`);
|
|
233
|
+
}
|
|
234
|
+
// Billing records — a card must reference a billing.id, so create one first.
|
|
235
|
+
async createBilling(customerId, billing) {
|
|
236
|
+
return this.request("POST", `${this.base}/${customerId}/billing-information`, billing);
|
|
237
|
+
}
|
|
238
|
+
async listBilling(customerId) {
|
|
239
|
+
return this.request("GET", `${this.base}/${customerId}/billing-information`);
|
|
240
|
+
}
|
|
241
|
+
async listCards(customerId) {
|
|
242
|
+
return this.request("GET", `${this.base}/${customerId}/cards`);
|
|
243
|
+
}
|
|
244
|
+
// Add a card with raw PAN (PCI-scoped path). exp is mm/yy.
|
|
245
|
+
async addCard(customerId, card) {
|
|
246
|
+
return this.request("POST", `${this.base}/${customerId}/card`, {
|
|
247
|
+
terminal: { id: this.opts.terminalId },
|
|
248
|
+
number: card.number,
|
|
249
|
+
exp: card.exp,
|
|
250
|
+
cvv: card.cvv,
|
|
251
|
+
holderName: card.holderName,
|
|
252
|
+
...card.billingId != null ? { billing: { id: card.billingId } } : {}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async deleteCard(customerId, cardId) {
|
|
256
|
+
return this.request("DELETE", `${this.base}/${customerId}/card/${cardId}`);
|
|
257
|
+
}
|
|
258
|
+
async request(method, url, body) {
|
|
259
|
+
const resp = await fetch(url, {
|
|
260
|
+
method,
|
|
261
|
+
headers: {
|
|
262
|
+
Authorization: `Bearer ${this.opts.apiToken}`,
|
|
263
|
+
"Content-Type": "application/json"
|
|
264
|
+
},
|
|
265
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
266
|
+
});
|
|
267
|
+
const text = await resp.text();
|
|
268
|
+
const json = text ? JSON.parse(text) : {};
|
|
269
|
+
if (!resp.ok) {
|
|
270
|
+
const msg = json?.message ?? text;
|
|
271
|
+
throw new Error(`Kadima Vault HTTP ${resp.status}: ${msg}`);
|
|
272
|
+
}
|
|
273
|
+
return json;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/providers/kadima-card.ts
|
|
278
|
+
var KadimaCardProviderService = class extends AbstractPaymentProvider {
|
|
279
|
+
static {
|
|
280
|
+
this.identifier = "kadima-card";
|
|
281
|
+
}
|
|
282
|
+
constructor(container, options) {
|
|
283
|
+
super(container, options);
|
|
284
|
+
this.options_ = options;
|
|
285
|
+
this.client = new KadimaCardClient(options);
|
|
286
|
+
this.vault = new KadimaVaultClient(options);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* No money moves. Mint a Hosted Fields token so the storefront can collect the
|
|
290
|
+
* card client-side. The returned `data` is public — only the HF token + terminal.
|
|
291
|
+
*/
|
|
292
|
+
async initiatePayment(input) {
|
|
293
|
+
const domain = input.data?.domain ?? "";
|
|
294
|
+
const hf = await this.client.createHostedFieldsToken({
|
|
295
|
+
domain,
|
|
296
|
+
saveCard: input.context?.account_holder ? "optional" : "disabled"
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
// No Kadima transaction exists yet; use a local placeholder id.
|
|
300
|
+
id: `kadima_hf_${hf.access_token.slice(-12)}`,
|
|
301
|
+
data: {
|
|
302
|
+
hostedFieldsToken: hf.access_token,
|
|
303
|
+
terminalId: this.options_.terminalId,
|
|
304
|
+
amount: input.amount,
|
|
305
|
+
currency_code: input.currency_code
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Two paths (see docs/storefront/hosted-fields-example.md):
|
|
311
|
+
*
|
|
312
|
+
* A) Hosted Fields (new card): the BROWSER already performed the payment. No
|
|
313
|
+
* card token reaches us. We return `pending` and let the `transaction/create`
|
|
314
|
+
* webhook (keyed by externalId === session_id) authorize the session.
|
|
315
|
+
*
|
|
316
|
+
* B) Server-to-server (stored CustomerVault token / recurring / MOTO): we hold
|
|
317
|
+
* a reusable card token and charge directly. captureMethod=auth → /payment/auth;
|
|
318
|
+
* =sale → /payment/sale.
|
|
319
|
+
*/
|
|
320
|
+
async authorizePayment(input) {
|
|
321
|
+
const cardToken = input.data?.cardToken;
|
|
322
|
+
const sessionId = input.data?.session_id;
|
|
323
|
+
if (!cardToken) {
|
|
324
|
+
return { status: "pending", data: { ...input.data, externalId: sessionId } };
|
|
325
|
+
}
|
|
326
|
+
const amount = Number(input.data?.amount);
|
|
327
|
+
const method = this.options_.captureMethod ?? "auth";
|
|
328
|
+
const txn = method === "sale" ? await this.client.sale({ amount, cardToken, externalId: sessionId }) : await this.client.auth({ amount, cardToken, externalId: sessionId });
|
|
329
|
+
return {
|
|
330
|
+
status: method === "sale" ? "captured" : "authorized",
|
|
331
|
+
data: { id: txn.id, captured: txn.captured, raw: txn }
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
async capturePayment(input) {
|
|
335
|
+
const id = String(input.data?.id);
|
|
336
|
+
if (input.data?.captured) return { data: input.data };
|
|
337
|
+
const txn = await this.client.capture(id);
|
|
338
|
+
return { data: { ...input.data, captured: txn.captured, raw: txn } };
|
|
339
|
+
}
|
|
340
|
+
async refundPayment(input) {
|
|
341
|
+
const id = String(input.data?.id);
|
|
342
|
+
const txn = await this.client.refund(id, Number(input.amount));
|
|
343
|
+
return { data: { ...input.data, refundId: txn.id, raw: txn } };
|
|
344
|
+
}
|
|
345
|
+
async cancelPayment(input) {
|
|
346
|
+
const id = String(input.data?.id);
|
|
347
|
+
const txn = await this.client.refund(id);
|
|
348
|
+
return { data: { ...input.data, canceled: true, raw: txn } };
|
|
349
|
+
}
|
|
350
|
+
async getPaymentStatus(input) {
|
|
351
|
+
const d = input.data ?? {};
|
|
352
|
+
if (d.refundId) return { status: "captured", data: d };
|
|
353
|
+
if (d.captured) return { status: "captured", data: d };
|
|
354
|
+
if (d.id) return { status: "authorized", data: d };
|
|
355
|
+
return { status: "pending", data: d };
|
|
356
|
+
}
|
|
357
|
+
async retrievePayment(input) {
|
|
358
|
+
return { data: input.data ?? {} };
|
|
359
|
+
}
|
|
360
|
+
async updatePayment(input) {
|
|
361
|
+
return { data: { ...input.data, amount: input.amount } };
|
|
362
|
+
}
|
|
363
|
+
async deletePayment(input) {
|
|
364
|
+
return { data: input.data ?? {} };
|
|
365
|
+
}
|
|
366
|
+
// --- Account holders & saved cards (CustomerVault) -----------------------
|
|
367
|
+
/** Create a CustomerVault customer for this Medusa customer. */
|
|
368
|
+
async createAccountHolder(input) {
|
|
369
|
+
const c = input.context.customer;
|
|
370
|
+
const customer = await this.vault.createCustomer({
|
|
371
|
+
firstName: c.first_name ?? void 0,
|
|
372
|
+
lastName: c.last_name ?? void 0,
|
|
373
|
+
company: c.company_name ?? void 0,
|
|
374
|
+
email: c.email,
|
|
375
|
+
phone: c.phone ?? void 0,
|
|
376
|
+
identificator: c.id
|
|
377
|
+
// Medusa customer id
|
|
378
|
+
});
|
|
379
|
+
return { id: String(customer.id), data: { ...customer } };
|
|
380
|
+
}
|
|
381
|
+
async deleteAccountHolder(input) {
|
|
382
|
+
const id = input.context.account_holder?.data?.id;
|
|
383
|
+
if (id) await this.vault.deleteCustomer(id);
|
|
384
|
+
return {};
|
|
385
|
+
}
|
|
386
|
+
async listPaymentMethods(input) {
|
|
387
|
+
const customerId = input.context?.account_holder?.data?.id;
|
|
388
|
+
if (!customerId) return [];
|
|
389
|
+
const { items } = await this.vault.listCards(customerId);
|
|
390
|
+
return (items ?? []).map((card) => ({
|
|
391
|
+
// The card token is the id we charge with later (card.token).
|
|
392
|
+
id: card.token,
|
|
393
|
+
data: { ...card }
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Save a card to the vault. Two inputs are supported in `data`:
|
|
398
|
+
* - raw card { number, exp, cvv, holderName } → POST .../card (PCI-scoped)
|
|
399
|
+
* - an already-tokenized card { token } → stored as-is (from Hosted
|
|
400
|
+
* Fields card-token, since there is no attach-token-to-vault endpoint)
|
|
401
|
+
*/
|
|
402
|
+
async savePaymentMethod(input) {
|
|
403
|
+
const data = input.data ?? {};
|
|
404
|
+
const customerId = input.context?.account_holder?.data?.id;
|
|
405
|
+
if (data.token && !data.number) {
|
|
406
|
+
return { id: data.token, data };
|
|
407
|
+
}
|
|
408
|
+
if (!customerId) {
|
|
409
|
+
throw new Error("savePaymentMethod requires an account holder (vault customer)");
|
|
410
|
+
}
|
|
411
|
+
const card = await this.vault.addCard(customerId, {
|
|
412
|
+
number: data.number,
|
|
413
|
+
exp: data.exp,
|
|
414
|
+
cvv: data.cvv,
|
|
415
|
+
holderName: data.holderName
|
|
416
|
+
});
|
|
417
|
+
return { id: card.token, data: { ...card } };
|
|
418
|
+
}
|
|
419
|
+
async getWebhookActionAndData(payload) {
|
|
420
|
+
const data = payload.data;
|
|
421
|
+
const headers = payload.headers;
|
|
422
|
+
const ok = verifySignature(
|
|
423
|
+
this.options_.webhookSecret,
|
|
424
|
+
{
|
|
425
|
+
id: data.id,
|
|
426
|
+
module: data.module,
|
|
427
|
+
action: data.action,
|
|
428
|
+
date: data.date
|
|
429
|
+
},
|
|
430
|
+
headers["webhook-signature"] || headers["Webhook-Signature"]
|
|
431
|
+
);
|
|
432
|
+
if (!ok || data.module !== "transaction") {
|
|
433
|
+
return { action: "not_supported" };
|
|
434
|
+
}
|
|
435
|
+
const txn = data.data ?? {};
|
|
436
|
+
const session_id = txn.externalId ?? "";
|
|
437
|
+
const amount = new BigNumber(Number(txn.amount ?? 0));
|
|
438
|
+
switch (data.action) {
|
|
439
|
+
case "create": {
|
|
440
|
+
const action = txn.captured ? "captured" : "authorized";
|
|
441
|
+
return { action, data: { session_id, amount } };
|
|
442
|
+
}
|
|
443
|
+
case "refund":
|
|
444
|
+
return { action: "captured", data: { session_id, amount } };
|
|
445
|
+
default:
|
|
446
|
+
return { action: "not_supported" };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
var kadima_card_default = KadimaCardProviderService;
|
|
451
|
+
|
|
452
|
+
export {
|
|
453
|
+
kadima_card_default
|
|
454
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DASHBOARD_HOSTS,
|
|
3
|
+
verifySignature
|
|
4
|
+
} from "./chunk-A4QO3PSZ.js";
|
|
5
|
+
|
|
6
|
+
// src/providers/kadima-ach.ts
|
|
7
|
+
import { AbstractPaymentProvider } from "@medusajs/framework/utils";
|
|
8
|
+
import { BigNumber } from "@medusajs/framework/utils";
|
|
9
|
+
|
|
10
|
+
// src/lib/kadima-ach-client.ts
|
|
11
|
+
var KadimaAchClient = class {
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.opts = opts;
|
|
14
|
+
this.base = `${opts.sandbox ? DASHBOARD_HOSTS.sandbox : DASHBOARD_HOSTS.prod}/api/ach`;
|
|
15
|
+
}
|
|
16
|
+
// Create returns { id }. NOTE: id is a string in the minimal response but
|
|
17
|
+
// numeric in the save-customer response — treat it as string | number.
|
|
18
|
+
/** Create a debit. Returns the new ACH transaction id. */
|
|
19
|
+
async debit(input) {
|
|
20
|
+
return this.request(this.base, "POST", this.buildBody(input, "Debit"));
|
|
21
|
+
}
|
|
22
|
+
/** Offsetting credit — refunds a settled debit (PPD/CCD only). */
|
|
23
|
+
async credit(input) {
|
|
24
|
+
return this.request(this.base, "POST", this.buildBody(input, "Credit"));
|
|
25
|
+
}
|
|
26
|
+
/** void | cancel | verify (void only while Held/Pending/Submitted). */
|
|
27
|
+
async action(id, action) {
|
|
28
|
+
return this.request(`${this.base}/${id}/${action}`, "POST");
|
|
29
|
+
}
|
|
30
|
+
async get(id) {
|
|
31
|
+
return this.request(`${this.base}/${id}`, "GET");
|
|
32
|
+
}
|
|
33
|
+
// --- Customer Vault ------------------------------------------------------
|
|
34
|
+
async createCustomer(data) {
|
|
35
|
+
return this.request(`${this.base}/customer`, "POST", data);
|
|
36
|
+
}
|
|
37
|
+
async addAccount(customerId, account) {
|
|
38
|
+
return this.request(`${this.base}/customer/${customerId}/account`, "POST", account);
|
|
39
|
+
}
|
|
40
|
+
buildBody(input, transactionType) {
|
|
41
|
+
const body = {
|
|
42
|
+
amount: input.amount,
|
|
43
|
+
transactionType,
|
|
44
|
+
SECCode: input.secCode ?? this.opts.secCode ?? "WEB",
|
|
45
|
+
dba: { id: this.opts.dbaId },
|
|
46
|
+
...input.tax != null ? { tax: input.tax } : {},
|
|
47
|
+
...input.memo ? { memo: input.memo } : {},
|
|
48
|
+
...input.addendaText ? { addendaText: input.addendaText } : {}
|
|
49
|
+
};
|
|
50
|
+
if (input.customerId) {
|
|
51
|
+
body.customer = { id: input.customerId };
|
|
52
|
+
if (input.accountId) body.account = { id: input.accountId };
|
|
53
|
+
} else {
|
|
54
|
+
body.accountName = input.accountName;
|
|
55
|
+
body.accountNumber = input.accountNumber;
|
|
56
|
+
body.routingNumber = input.routingNumber;
|
|
57
|
+
body.accountType = input.accountType ?? "Checking";
|
|
58
|
+
body.customer = {
|
|
59
|
+
...input.saveCustomer ? { save: "Yes" } : {},
|
|
60
|
+
...input.customer
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
async request(url, method, body) {
|
|
66
|
+
const resp = await fetch(url, {
|
|
67
|
+
method,
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${this.opts.apiToken}`,
|
|
70
|
+
"Content-Type": "application/json"
|
|
71
|
+
},
|
|
72
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
73
|
+
});
|
|
74
|
+
const text = await resp.text();
|
|
75
|
+
const json = text ? JSON.parse(text) : {};
|
|
76
|
+
if (!resp.ok) {
|
|
77
|
+
const msg = json?.message ?? text;
|
|
78
|
+
throw new Error(`Kadima ACH HTTP ${resp.status}: ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
return json;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/providers/kadima-ach.ts
|
|
85
|
+
var KadimaAchProviderService = class extends AbstractPaymentProvider {
|
|
86
|
+
static {
|
|
87
|
+
this.identifier = "kadima-ach";
|
|
88
|
+
}
|
|
89
|
+
constructor(container, options) {
|
|
90
|
+
super(container, options);
|
|
91
|
+
this.options_ = options;
|
|
92
|
+
this.client = new KadimaAchClient(options);
|
|
93
|
+
}
|
|
94
|
+
async initiatePayment(input) {
|
|
95
|
+
return {
|
|
96
|
+
id: `kadima_ach_init`,
|
|
97
|
+
data: { amount: input.amount, currency_code: input.currency_code }
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Submit the debit. The item is created `Pending` → we return `authorized`
|
|
102
|
+
* (funds are NOT settled yet). The `Settled` webhook later drives `captured`.
|
|
103
|
+
*
|
|
104
|
+
* Correlation: we stamp `customer.identifier = session_id` so the achObject on
|
|
105
|
+
* the webhook carries our Medusa session id back to us.
|
|
106
|
+
*/
|
|
107
|
+
async authorizePayment(input) {
|
|
108
|
+
const sessionId = input.data?.session_id;
|
|
109
|
+
const inlineCustomer = input.data?.customer ?? {};
|
|
110
|
+
const created = await this.client.debit({
|
|
111
|
+
amount: Number(input.data?.amount),
|
|
112
|
+
tax: input.data?.tax != null ? Number(input.data.tax) : void 0,
|
|
113
|
+
customerId: input.data?.customerId,
|
|
114
|
+
accountId: input.data?.accountId,
|
|
115
|
+
// inline bank account (top-level fields) for a first-time payer
|
|
116
|
+
accountName: input.data?.accountName,
|
|
117
|
+
accountNumber: input.data?.accountNumber,
|
|
118
|
+
routingNumber: input.data?.routingNumber,
|
|
119
|
+
accountType: input.data?.accountType,
|
|
120
|
+
saveCustomer: Boolean(input.data?.saveCustomer),
|
|
121
|
+
customer: { ...inlineCustomer, identifier: sessionId }
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
status: "authorized",
|
|
125
|
+
data: { id: created.id, achStatus: "Pending" }
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** No synchronous capture for ACH — real capture is the Settled webhook. */
|
|
129
|
+
async capturePayment(input) {
|
|
130
|
+
return { data: input.data ?? {} };
|
|
131
|
+
}
|
|
132
|
+
async refundPayment(input) {
|
|
133
|
+
const id = String(input.data?.id);
|
|
134
|
+
const status = input.data?.achStatus;
|
|
135
|
+
if (status === "Pending" || status === "Submitted") {
|
|
136
|
+
await this.client.action(id, "void");
|
|
137
|
+
return { data: { ...input.data, voided: true } };
|
|
138
|
+
}
|
|
139
|
+
const credit = await this.client.credit({
|
|
140
|
+
amount: Number(input.amount),
|
|
141
|
+
customerId: input.data?.customerId
|
|
142
|
+
});
|
|
143
|
+
return { data: { ...input.data, creditId: credit.id } };
|
|
144
|
+
}
|
|
145
|
+
async cancelPayment(input) {
|
|
146
|
+
const id = String(input.data?.id);
|
|
147
|
+
await this.client.action(id, "void");
|
|
148
|
+
return { data: { ...input.data, canceled: true } };
|
|
149
|
+
}
|
|
150
|
+
async getPaymentStatus(input) {
|
|
151
|
+
const s = input.data?.achStatus;
|
|
152
|
+
switch (s) {
|
|
153
|
+
case "Settled":
|
|
154
|
+
return { status: "captured", data: input.data ?? {} };
|
|
155
|
+
case "Returned":
|
|
156
|
+
case "Voided":
|
|
157
|
+
return { status: "canceled", data: input.data ?? {} };
|
|
158
|
+
default:
|
|
159
|
+
return { status: "authorized", data: input.data ?? {} };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async retrievePayment(input) {
|
|
163
|
+
return { data: input.data ?? {} };
|
|
164
|
+
}
|
|
165
|
+
async updatePayment(input) {
|
|
166
|
+
return { data: { ...input.data, amount: input.amount } };
|
|
167
|
+
}
|
|
168
|
+
async deletePayment(input) {
|
|
169
|
+
return { data: input.data ?? {} };
|
|
170
|
+
}
|
|
171
|
+
async getWebhookActionAndData(payload) {
|
|
172
|
+
const data = payload.data;
|
|
173
|
+
const headers = payload.headers;
|
|
174
|
+
const ok = verifySignature(
|
|
175
|
+
this.options_.webhookSecret,
|
|
176
|
+
{ id: data.id, module: data.module, action: data.action, date: data.date },
|
|
177
|
+
headers["webhook-signature"] || headers["Webhook-Signature"]
|
|
178
|
+
);
|
|
179
|
+
if (!ok || data.module !== "ach") return { action: "not_supported" };
|
|
180
|
+
const ach = data.data ?? {};
|
|
181
|
+
const sessionId = ach.customer?.identifier ?? "";
|
|
182
|
+
const amount = new BigNumber(Number(ach.amount ?? 0));
|
|
183
|
+
const status = ach.transactionStatus;
|
|
184
|
+
if (data.action === "updateStatus" && status === "Settled") {
|
|
185
|
+
return { action: "captured", data: { session_id: sessionId, amount } };
|
|
186
|
+
}
|
|
187
|
+
if (data.action === "updateStatus" && (status === "Returned" || status === "Voided")) {
|
|
188
|
+
return { action: "failed", data: { session_id: sessionId, amount } };
|
|
189
|
+
}
|
|
190
|
+
return { action: "not_supported" };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var kadima_ach_default = KadimaAchProviderService;
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
kadima_ach_default
|
|
197
|
+
};
|