diff --git a/main.ts b/main.ts index 1b9ea66..ab8c7c3 100755 --- a/main.ts +++ b/main.ts @@ -201,3 +201,5 @@ if (isAppReady) { // if (import.meta.main) { // await app.listen(); // } + + diff --git a/routes/api/payments/create_intent.ts b/routes/api/payments/create_intent.ts new file mode 100644 index 0000000..a0a4315 --- /dev/null +++ b/routes/api/payments/create_intent.ts @@ -0,0 +1,93 @@ +import { define } from "../../../utils.ts"; +import { createPaymentTransaction } from "../../../services/paymentService.ts"; + +type CreateIntentBody = { + lease_id: string; + amount: number; + payment_method?: string; + currency?: string; +}; + +export const handler = define.handlers({ + async POST(ctx) { + try { + const body = await ctx.req.json() as CreateIntentBody; + const { lease_id, amount, payment_method = "ach", currency = "usd" } = + body || {} as CreateIntentBody; + + if (!lease_id || !amount || Number.isNaN(amount)) { + return new Response( + JSON.stringify({ success: false, error: "Invalid request body" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const sandbox = Deno.env.get("DISABLE_EXTERNAL_APIS") === "true" || + Deno.env.get("SANDBOX_MODE") === "true"; + + if (sandbox) { + const localId = `pi_local_${crypto.randomUUID()}`; + const [transaction] = await createPaymentTransaction( + lease_id, + amount, + Date.now(), + payment_method, + "completed", + localId, + ); + return new Response( + JSON.stringify({ success: true, mode: "local", transaction }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + // Production Stripe flow (guarded by env flags) + const secret = Deno.env.get("STRIPE_SECRET_KEY"); + if (!secret) { + return new Response( + JSON.stringify({ success: false, error: "Stripe not configured" }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + const params = new URLSearchParams(); + params.set("amount", String(Math.round(amount * 100))); + params.set("currency", currency); + params.set("automatic_payment_methods[enabled]", "true"); + + const resp = await fetch("https://api.stripe.com/v1/payment_intents", { + method: "POST", + headers: { + "Authorization": `Bearer ${secret}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!resp.ok) { + const text = await resp.text(); + return new Response( + JSON.stringify({ success: false, error: text }), + { status: 502, headers: { "Content-Type": "application/json" } }, + ); + } + + const pi = await resp.json(); + return new Response( + JSON.stringify({ + success: true, + mode: "stripe", + client_secret: pi.client_secret, + payment_intent_id: pi.id, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } catch (err) { + return new Response( + JSON.stringify({ success: false, error: String(err) }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, +}); + diff --git a/routes/api/payments/webhook.ts b/routes/api/payments/webhook.ts new file mode 100644 index 0000000..1fb1112 --- /dev/null +++ b/routes/api/payments/webhook.ts @@ -0,0 +1,123 @@ +import { define } from "../../../utils.ts"; +import { + createPaymentTransaction, + getTransactionByStripeId, +} from "../../../services/paymentService.ts"; + +async function hmacSHA256Hex(key: string, message: string): Promise { + const enc = new TextEncoder(); + const cryptoKey = await crypto.subtle.importKey( + "raw", + enc.encode(key), + { name: "HMAC", hash: { name: "SHA-256" } }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message)); + const bytes = new Uint8Array(sig); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function parseStripeSignature(header: string | null) { + if (!header) return null; + const parts = header.split(",").map((p) => p.trim()); + const out: Record = {}; + for (const part of parts) { + const [k, v] = part.split("="); + if (k && v) out[k] = v; + } + if (!out["t"] || !out["v1"]) return null; + return { timestamp: out["t"], signature: out["v1"] }; +} + +export const handler = define.handlers({ + async POST(ctx) { + try { + const raw = await ctx.req.text(); + const sandbox = Deno.env.get("DISABLE_EXTERNAL_APIS") === "true" || + Deno.env.get("SANDBOX_MODE") === "true"; + + if (sandbox) { + // Accept simulated event: { type, data: { id, amount, lease_id, payment_method } } + const { type, data } = JSON.parse(raw || "{}"); + if (type === "payment_intent.succeeded" && data?.id) { + const existing = await getTransactionByStripeId(data.id); + if (!existing) { + const amountNumber = typeof data.amount === "number" + ? data.amount + : Number(data.amount); + const amount = Number.isFinite(amountNumber) ? amountNumber : 0; + await createPaymentTransaction( + data.lease_id, + amount, + Date.now(), + data.payment_method || "ach", + "completed", + data.id, + ); + } + } + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Production Stripe verification + const secret = Deno.env.get("STRIPE_WEBHOOK_SECRET"); + const sigHeader = ctx.req.headers.get("stripe-signature"); + if (!secret || !sigHeader) { + return new Response(JSON.stringify({ error: "Missing signature" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const parsed = parseStripeSignature(sigHeader); + if (!parsed) { + return new Response(JSON.stringify({ error: "Bad signature header" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + const signedPayload = `${parsed.timestamp}.${raw}`; + const expected = await hmacSHA256Hex(secret, signedPayload); + if (expected !== parsed.signature) { + return new Response(JSON.stringify({ error: "Invalid signature" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const event = JSON.parse(raw || "{}"); + if (event?.type === "payment_intent.succeeded") { + const obj = event.data?.object ?? {}; + const stripeId = obj.id as string; + const amountCents = obj.amount_received ?? obj.amount ?? 0; + const amount = Math.round(Number(amountCents)) / 100; + const leaseId = obj.metadata?.lease_id ?? ""; + const method = obj.payment_method_types?.[0] ?? "card"; + const existing = await getTransactionByStripeId(stripeId); + if (!existing) { + await createPaymentTransaction( + leaseId, + amount, + Date.now(), + method, + "completed", + stripeId, + ); + } + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response(JSON.stringify({ error: String(err) }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, +}); diff --git a/routes/api/receipt/[id].ts b/routes/api/receipt/[id].ts new file mode 100644 index 0000000..18b025e --- /dev/null +++ b/routes/api/receipt/[id].ts @@ -0,0 +1,52 @@ +import { define } from "../../../../utils.ts"; +import { getTransactionByStripeId } from "../../../../services/paymentService.ts"; + +export const handler = define.handlers({ + async GET(ctx) { + const stripeId = ctx.params.id; + if (!stripeId) { + return new Response("Not Found", { status: 404 }); + } + const tx = await getTransactionByStripeId(stripeId); + if (!tx) { + return new Response("Receipt not found", { status: 404 }); + } + const amount = tx.amount ?? 0; + const date = tx.payment_date || tx.created_on || Date.now(); + const leaseId = tx.lease_id || ""; + const method = tx.payment_method || "-"; + + const html = ` + + + + + Payment Receipt + + + +
+

Payment Receipt

+
Stripe/Payment ID
${stripeId}
+
Lease ID
${leaseId}
+
Amount
$${amount.toLocaleString()}
+
Method
${method}
+
Date
${new Date(date).toLocaleString()}
+
+ + `; + + return new Response(html, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }, +}); + diff --git a/routes/api/retrieve/payment_transactions.ts b/routes/api/retrieve/payment_transactions.ts index a04e1b7..bd6ff5d 100644 --- a/routes/api/retrieve/payment_transactions.ts +++ b/routes/api/retrieve/payment_transactions.ts @@ -43,3 +43,20 @@ export const handler = define.handlers({ } }, }); + + + + + + + + + + + + + + + + + diff --git a/routes/landlord/payments/index.tsx b/routes/landlord/payments/index.tsx index b973a2f..6f0dd53 100644 --- a/routes/landlord/payments/index.tsx +++ b/routes/landlord/payments/index.tsx @@ -282,3 +282,4 @@ export default define.page(function LandlordPayments({ data }) { ); }); + diff --git a/routes/landlord/tenants/invite.tsx b/routes/landlord/tenants/invite.tsx index b43eebc..0df6389 100644 --- a/routes/landlord/tenants/invite.tsx +++ b/routes/landlord/tenants/invite.tsx @@ -150,3 +150,40 @@ export default define.page(function InviteTenantPage({ data }) { ); }); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/routes/tenant/rent/confirmation/index.tsx b/routes/tenant/rent/confirmation/index.tsx new file mode 100644 index 0000000..ef0090f --- /dev/null +++ b/routes/tenant/rent/confirmation/index.tsx @@ -0,0 +1,58 @@ +import { define } from "../../../../utils.ts"; +import { page } from "fresh"; + +export const handler = define.handlers({ + async GET(ctx) { + const url = new URL(ctx.req.url); + const success = url.searchParams.get("success") === "true"; + const amount = url.searchParams.get("amount") ?? ""; + return page({ success, amount }); + }, +}); + +export default define.page(function RentConfirmation({ data }) { + const { success, amount } = data as { success: boolean; amount?: string }; + return ( +
+
+ {success + ? ( + <> +

+ Payment Successful +

+ {amount && ( +

Amount: ${amount}

+ )} +

+ A receipt is available from your payment history. +

+ + View Payments + + + ) + : ( + <> +

+ Payment Failed +

+

+ Please try again or contact support. +

+ + Back to Rent Payment + + + )} +
+
+ ); +}); + diff --git a/routes/tenant/rent/pay/index.tsx b/routes/tenant/rent/pay/index.tsx index 01a9afc..93ba0b7 100644 --- a/routes/tenant/rent/pay/index.tsx +++ b/routes/tenant/rent/pay/index.tsx @@ -347,3 +347,4 @@ export default define.page( ); }, ); + diff --git a/services/paymentService.ts b/services/paymentService.ts index bd5df7e..5d19831 100644 --- a/services/paymentService.ts +++ b/services/paymentService.ts @@ -387,3 +387,4 @@ export async function cancelAutoPayment( .where(eq(leases.id, lease_id)) .returning(); } + diff --git a/utils/database.ts b/utils/database.ts index 0764722..b957f61 100644 --- a/utils/database.ts +++ b/utils/database.ts @@ -592,3 +592,4 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) { tokenResponse, ); } +