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) {
|
// if (import.meta.main) {
|
||||||
// await app.listen();
|
// 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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -150,3 +150,40 @@ export default define.page(function InviteTenantPage({ data }) {
|
|||||||
</div>
|
</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))
|
.where(eq(leases.id, lease_id))
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -592,3 +592,4 @@ export async function upsertAccessToken(tokenResponse: AccessTokenResponse) {
|
|||||||
tokenResponse,
|
tokenResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user