Merge pull request #41 from nathanAndrinoid/open-swe/74454327-f96c-4712-a615-375947855221
[WIP]: Focused implementation plan to complete high-priority TODOs (payments, ACME endpoint, token uniqueness) and deliver key feature integrations (dashboards, calendar, documents, reminders)
This commit is contained in:
commit
8d38a5eb74
2
main.ts
2
main.ts
@ -201,3 +201,5 @@ if (isAppReady) {
|
||||
// if (import.meta.main) {
|
||||
// await app.listen();
|
||||
// }
|
||||
|
||||
|
||||
|
||||
93
routes/api/payments/create_intent.ts
Normal file
93
routes/api/payments/create_intent.ts
Normal file
@ -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" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
123
routes/api/payments/webhook.ts
Normal file
123
routes/api/payments/webhook.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { define } from "../../../utils.ts";
|
||||
import {
|
||||
createPaymentTransaction,
|
||||
getTransactionByStripeId,
|
||||
} from "../../../services/paymentService.ts";
|
||||
|
||||
async function hmacSHA256Hex(key: string, message: string): Promise<string> {
|
||||
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<string, string> = {};
|
||||
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
52
routes/api/receipt/[id].ts
Normal file
52
routes/api/receipt/[id].ts
Normal file
@ -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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Payment Receipt</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 24px; color: #111827; }
|
||||
.card { max-width: 560px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; }
|
||||
h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||
.label { color: #6b7280; }
|
||||
.value { font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Payment Receipt</h1>
|
||||
<div class="row"><div class="label">Stripe/Payment ID</div><div class="value">${stripeId}</div></div>
|
||||
<div class="row"><div class="label">Lease ID</div><div class="value">${leaseId}</div></div>
|
||||
<div class="row"><div class="label">Amount</div><div class="value">$${amount.toLocaleString()}</div></div>
|
||||
<div class="row"><div class="label">Method</div><div class="value">${method}</div></div>
|
||||
<div class="row"><div class="label">Date</div><div class="value">${new Date(date).toLocaleString()}</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -43,3 +43,20 @@ export const handler = define.handlers({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -282,3 +282,4 @@ export default define.page(function LandlordPayments({ data }) {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -150,3 +150,40 @@ export default define.page(function InviteTenantPage({ data }) {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
58
routes/tenant/rent/confirmation/index.tsx
Normal file
58
routes/tenant/rent/confirmation/index.tsx
Normal file
@ -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 (
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="max-w-lg w-full bg-white shadow rounded-lg p-8 text-center">
|
||||
{success
|
||||
? (
|
||||
<>
|
||||
<h1 class="text-2xl font-bold text-green-700 mb-2">
|
||||
Payment Successful
|
||||
</h1>
|
||||
{amount && (
|
||||
<p class="text-gray-700 mb-4">Amount: ${amount}</p>
|
||||
)}
|
||||
<p class="text-gray-600 mb-6">
|
||||
A receipt is available from your payment history.
|
||||
</p>
|
||||
<a
|
||||
href="/tenant/payments"
|
||||
class="inline-flex items-center px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
View Payments
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<h1 class="text-2xl font-bold text-red-700 mb-2">
|
||||
Payment Failed
|
||||
</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Please try again or contact support.
|
||||
</p>
|
||||
<a
|
||||
href="/tenant/rent/pay"
|
||||
class="inline-flex items-center px-4 py-2 rounded-md bg-gray-700 text-white hover:bg-gray-800"
|
||||
>
|
||||
Back to Rent Payment
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -347,3 +347,4 @@ export default define.page(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -387,3 +387,4 @@ export async function cancelAutoPayment(
|
||||
.where(eq(leases.id, lease_id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
||||
@ -592,3 +592,4 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) {
|
||||
tokenResponse,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user