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:
nathanAndrinoid 2026-01-21 16:52:21 -06:00 committed by GitHub
commit 8d38a5eb74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 386 additions and 0 deletions

View File

@ -201,3 +201,5 @@ if (isAppReady) {
// if (import.meta.main) {
// await app.listen();
// }

View 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" } },
);
}
},
});

View 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" },
});
}
},
});

View 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" },
});
},
});

View File

@ -43,3 +43,20 @@ export const handler = define.handlers({
}
},
});

View File

@ -282,3 +282,4 @@ export default define.page(function LandlordPayments({ data }) {
</div>
);
});

View File

@ -150,3 +150,40 @@ export default define.page(function InviteTenantPage({ data }) {
</div>
);
});

View 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>
);
});

View File

@ -347,3 +347,4 @@ export default define.page(
);
},
);

View File

@ -387,3 +387,4 @@ export async function cancelAutoPayment(
.where(eq(leases.id, lease_id))
.returning();
}

View File

@ -592,3 +592,4 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) {
tokenResponse,
);
}