Skip to content

Commit ea9ae07

Browse files
authored
Merge pull request #223 from codeableorg:payments-proposal
Integrate Culqi payment processing
2 parents 23a9536 + 82f325e commit ea9ae07

File tree

11 files changed

+178
-116
lines changed

11 files changed

+178
-116
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ DB_PASSWORD=your_db_password
1010

1111
# Admin Database (for database creation/deletion)
1212
ADMIN_DB_NAME=postgres
13+
14+
# Culqui Keys
15+
CULQI_PRIVATE_KEY="sk_test_xxx"
16+
VITE_CULQI_PUBLIC_KEY="pk_test_xxx"

.env.test

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
DATABASE_URL="postgresql://diego@localhost:5432/fullstock_test?schema=public"
22

33
# Admin Database (for database creation/deletion)
4-
ADMIN_DB_NAME=postgres
4+
ADMIN_DB_NAME=postgres
5+
6+
# Culqui Keys
7+
CULQI_PRIVATE_KEY="sk_test_xxx"
8+
VITE_CULQI_PUBLIC_KEY="pk_test_xxx"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "orders" ADD COLUMN "payment_id" TEXT;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ model Order {
103103
userId Int @map("user_id")
104104
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
105105
email String
106+
paymentId String? @map("payment_id")
106107
firstName String @map("first_name")
107108
lastName String @map("last_name")
108109
company String?

public/.well-known/appspecific/com.chrome.devtools.json

Whitespace-only changes.

src/hooks/use-culqui.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
export type CulqiChargeError = {
4+
object: "error";
5+
type: string;
6+
charge_id: string;
7+
code: string;
8+
decline_code: string | null;
9+
merchant_message: string;
10+
user_message: string;
11+
};
12+
13+
export interface CulqiInstance {
14+
open: () => void;
15+
close: () => void;
16+
token?: { id: string };
17+
error?: Error;
18+
culqi?: () => void;
19+
}
20+
21+
export type CulqiConstructorType = new (
22+
publicKey: string,
23+
config: object
24+
) => CulqiInstance;
25+
26+
declare global {
27+
interface Window {
28+
CulqiCheckout?: CulqiConstructorType;
29+
}
30+
}
31+
32+
// Return type explicitly includes the constructor function
33+
export function useCulqi() {
34+
const [CulqiCheckout, setCulqiCheckout] =
35+
useState<CulqiConstructorType | null>(null);
36+
const [loading, setLoading] = useState<boolean>(false);
37+
const [error, setError] = useState<Error | null>(null);
38+
const scriptRef = useRef<HTMLScriptElement | null>(null);
39+
40+
useEffect(() => {
41+
if (window.CulqiCheckout) {
42+
setCulqiCheckout(() => window.CulqiCheckout!);
43+
return;
44+
}
45+
46+
setLoading(true);
47+
const script = document.createElement("script");
48+
script.src = "https://js.culqi.com/checkout-js";
49+
script.async = true;
50+
scriptRef.current = script;
51+
52+
script.onload = () => {
53+
if (window.CulqiCheckout) {
54+
setCulqiCheckout(() => window.CulqiCheckout!);
55+
} else {
56+
setError(
57+
new Error("Culqi script loaded but CulqiCheckout object not found")
58+
);
59+
}
60+
setLoading(false);
61+
};
62+
63+
script.onerror = () => {
64+
setError(new Error("Failed to load CulqiCheckout script"));
65+
setLoading(false);
66+
};
67+
68+
document.head.appendChild(script);
69+
70+
return () => {
71+
if (scriptRef.current) {
72+
scriptRef.current.remove();
73+
}
74+
};
75+
}, []);
76+
77+
return { CulqiCheckout, loading, error };
78+
}

src/routes/checkout/index.tsx

Lines changed: 77 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { zodResolver } from "@hookform/resolvers/zod";
22
import { X } from "lucide-react";
3-
import { useEffect, useRef, useState } from "react";
3+
import { useEffect, useState } from "react";
44
import { useForm } from "react-hook-form";
55
import { redirect, useNavigation, useSubmit } from "react-router";
66
import { z } from "zod";
@@ -13,6 +13,11 @@ import {
1313
Separator,
1414
SelectField,
1515
} from "@/components/ui";
16+
import {
17+
useCulqi,
18+
type CulqiChargeError,
19+
type CulqiInstance,
20+
} from "@/hooks/use-culqui";
1621
import { calculateTotal, getCart } from "@/lib/cart";
1722
import { type CartItem } from "@/models/cart.model";
1823
import { getCurrentUser } from "@/services/auth.service";
@@ -22,20 +27,6 @@ import { commitSession, getSession } from "@/session.server";
2227

2328
import type { Route } from "./+types";
2429

25-
interface CulqiInstance {
26-
open: () => void;
27-
close: () => void;
28-
token?: { id: string };
29-
error?: Error;
30-
culqi?: () => void;
31-
}
32-
33-
declare global {
34-
interface Window {
35-
CulqiCheckout: new (publicKey: string, config: object) => CulqiInstance;
36-
}
37-
}
38-
3930
const countryOptions = [
4031
{ value: "AR", label: "Argentina" },
4132
{ value: "BO", label: "Bolivia" },
@@ -85,8 +76,10 @@ export async function action({ request }: Route.ActionArgs) {
8576
) as CartItem[];
8677
const token = formData.get("token") as string;
8778

79+
const total = Math.round(calculateTotal(cartItems) * 100);
80+
8881
const body = {
89-
amount: 2000, // TODO: Calculate total dynamically
82+
amount: total,
9083
currency_code: "PEN",
9184
email: shippingDetails.email,
9285
source_id: token,
@@ -97,18 +90,19 @@ export async function action({ request }: Route.ActionArgs) {
9790
method: "POST",
9891
headers: {
9992
"content-type": "application/json",
100-
Authorization: `Bearer sk_test_EC8oOLd3ZiCTKqjN`, // TODO: Use environment variable
93+
Authorization: `Bearer ${process.env.CULQI_PRIVATE_KEY}`,
10194
},
10295
body: JSON.stringify(body),
10396
});
10497

10598
if (!response.ok) {
106-
const errorData = await response.json();
99+
const errorData = (await response.json()) as CulqiChargeError;
107100
console.error("Error creating charge:", errorData);
108-
// TODO: Handle error appropriately
109-
throw new Error("Error processing payment");
101+
return { error: errorData.user_message || "Error processing payment" };
110102
}
111103

104+
const chargeData = await response.json();
105+
112106
const items = cartItems.map((item) => ({
113107
productId: item.product.id,
114108
quantity: item.quantity,
@@ -117,9 +111,13 @@ export async function action({ request }: Route.ActionArgs) {
117111
imgSrc: item.product.imgSrc,
118112
}));
119113

120-
// TODO
121-
// @ts-expect-error Arreglar el tipo de shippingDetails
122-
const { id: orderId } = await createOrder(items, shippingDetails); // TODO: Add payment information to the order
114+
const { id: orderId } = await createOrder(
115+
items,
116+
// TODO
117+
// @ts-expect-error Arreglar el tipo de shippingDetails
118+
shippingDetails,
119+
chargeData.id
120+
);
123121

124122
await deleteRemoteCart(request);
125123
const session = await getSession(request.headers.get("Cookie"));
@@ -151,14 +149,18 @@ export async function loader({ request }: Route.LoaderArgs) {
151149
return user ? { user, cart, total } : { cart, total };
152150
}
153151

154-
export default function Checkout({ loaderData }: Route.ComponentProps) {
152+
export default function Checkout({
153+
loaderData,
154+
actionData,
155+
}: Route.ComponentProps) {
155156
const { user, cart, total } = loaderData;
156157
const navigation = useNavigation();
157158
const submit = useSubmit();
158159
const loading = navigation.state === "submitting";
160+
const paymentError = actionData?.error;
159161

160162
const [culqui, setCulqui] = useState<CulqiInstance | null>(null);
161-
const scriptRef = useRef<HTMLScriptElement | null>(null);
163+
const { CulqiCheckout } = useCulqi();
162164

163165
const {
164166
register,
@@ -183,96 +185,56 @@ export default function Checkout({ loaderData }: Route.ComponentProps) {
183185
});
184186

185187
useEffect(() => {
186-
// Function to load the Culqi script
187-
const loadCulqiScript = (): Promise<Window["CulqiCheckout"]> => {
188-
return new Promise<Window["CulqiCheckout"]>((resolve, reject) => {
189-
if (window.CulqiCheckout) {
190-
resolve(window.CulqiCheckout);
191-
return;
192-
}
193-
194-
// Create script element
195-
const script = document.createElement("script");
196-
script.src = "https://js.culqi.com/checkout-js";
197-
script.async = true;
198-
199-
// Store reference for cleanup
200-
scriptRef.current = script;
201-
202-
script.onload = () => {
203-
if (window.CulqiCheckout) {
204-
resolve(window.CulqiCheckout);
205-
} else {
206-
reject(
207-
new Error(
208-
"Culqi script loaded but CulqiCheckout object not found"
209-
)
210-
);
211-
}
212-
};
213-
214-
script.onerror = () => {
215-
reject(new Error("Failed to load CulqiCheckout script"));
216-
};
217-
218-
document.head.appendChild(script);
219-
});
188+
if (!CulqiCheckout) return;
189+
190+
const config = {
191+
settings: {
192+
currency: "PEN",
193+
amount: Math.round(total * 100),
194+
},
195+
client: {
196+
email: user?.email,
197+
},
198+
options: {
199+
paymentMethods: {
200+
tarjeta: true,
201+
yape: false,
202+
},
203+
},
204+
appearance: {},
220205
};
221206

222-
loadCulqiScript()
223-
.then((CulqiCheckout) => {
224-
const config = {
225-
settings: {
226-
currency: "PEN",
227-
amount: total * 100,
228-
},
229-
client: {
230-
email: user?.email,
231-
},
232-
options: {
233-
paymentMethods: {
234-
tarjeta: true,
235-
yape: false,
236-
},
207+
const culqiInstance = new CulqiCheckout(
208+
import.meta.env.VITE_CULQI_PUBLIC_KEY as string,
209+
config
210+
);
211+
212+
culqiInstance.culqi = () => {
213+
if (culqiInstance.token) {
214+
const token = culqiInstance.token.id;
215+
culqiInstance.close();
216+
const formData = getValues();
217+
submit(
218+
{
219+
shippingDetailsJson: JSON.stringify(formData),
220+
cartItemsJson: JSON.stringify(cart.items),
221+
token,
237222
},
238-
appearance: {},
239-
};
240-
241-
const publicKey = "pk_test_Ws4NXfH95QXlZgaz";
242-
const culqiInstance = new CulqiCheckout(publicKey, config);
243-
244-
const handleCulqiAction = () => {
245-
if (culqiInstance.token) {
246-
const token = culqiInstance.token.id;
247-
culqiInstance.close();
248-
const formData = getValues();
249-
submit(
250-
{
251-
shippingDetailsJson: JSON.stringify(formData),
252-
cartItemsJson: JSON.stringify(cart.items),
253-
token,
254-
},
255-
{ method: "POST" }
256-
);
257-
} else {
258-
console.log("Error : ", culqiInstance.error);
259-
}
260-
};
261-
262-
culqiInstance.culqi = handleCulqiAction;
223+
{ method: "POST" }
224+
);
225+
} else {
226+
console.log("Error : ", culqiInstance.error);
227+
}
228+
};
263229

264-
setCulqui(culqiInstance);
265-
})
266-
.catch((error) => {
267-
console.error("Error loading Culqi script:", error);
268-
});
230+
setCulqui(culqiInstance);
269231

270232
return () => {
271-
if (scriptRef.current) {
272-
scriptRef.current.remove();
233+
if (culqiInstance) {
234+
culqiInstance.close();
273235
}
274236
};
275-
}, [total, user, submit, getValues, cart.items]);
237+
}, [total, user, submit, getValues, cart.items, CulqiCheckout]);
276238

277239
async function onSubmit() {
278240
if (culqui) {
@@ -397,12 +359,18 @@ export default function Checkout({ loaderData }: Route.ComponentProps) {
397359
/>
398360
</div>
399361
</fieldset>
400-
<Button size="xl" className="w-full mt-6" disabled={!isValid}>
362+
<Button
363+
size="xl"
364+
className="w-full mt-6"
365+
disabled={!isValid || !CulqiCheckout || loading}
366+
>
401367
{loading ? "Procesando..." : "Confirmar Orden"}
402368
</Button>
369+
{paymentError && (
370+
<p className="text-red-500 mt-4 text-center">{paymentError}</p>
371+
)}
403372
</form>
404373
</div>
405-
<div id="culqi-container"></div>
406374
</Container>
407375
</Section>
408376
);

src/routes/login/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export async function action({ request }: Route.ActionArgs) {
3232
try {
3333
// Proceso de login nuevo
3434
const user = await prisma.user.findUnique({ where: { email } });
35-
if (!user) {
35+
if (!user || user.isGuest) {
3636
return { error: "Correo electrónico o contraseña inválidos" };
3737
}
3838

0 commit comments

Comments
 (0)