Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 42s

This commit is contained in:
2026-05-27 22:06:32 +02:00
parent ad4f614130
commit ee6bb89fb6
20 changed files with 1857 additions and 122 deletions

View File

@@ -1,8 +1,14 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import {
getInvoiceById,
getTenantRequestById,
updateTenantRequestStatus,
} from "@/lib/db";
import { setTenantAnnotation } from "@/lib/k8s";
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser } from "@/types";
/**
* POST /api/admin/requests/[id]/reject
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
* suspendedAt — rejection doesn't reset it. The customer can submit
* a fresh resume request later if circumstances change, but that
* starts a new pending row and re-stamps the annotation.
*
* Phase 9b: provision rejections that have a linked paid setup
* invoice (setup_invoice_id) trigger an automatic full refund via
* the existing refundInvoice flow. The refund creates a credit
* note + Stripe refund + customer email — same paper trail any
* post-payment refund would have. Best-effort: a refund failure
* does NOT block the rejection (admin can re-refund manually via
* the invoice detail page if needed), but it's logged and surfaced
* in the response so admin sees what happened.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let user: SessionUser;
try {
await requirePlatformRole();
user = await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -65,6 +81,63 @@ export async function POST(
}
}
// Phase 9b: refund the setup-fee invoice if one is linked. Only
// applies to provision rejections; resume requests never have a
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
// the request was created before Phase 9b shipped, or the setup
// fee was 0).
const refundSummary: {
attempted: boolean;
succeeded: boolean;
error?: string;
} = { attempted: false, succeeded: false };
if (
tenantRequest.requestType === "provision" &&
tenantRequest.setupInvoiceId
) {
refundSummary.attempted = true;
try {
// refundInvoice expects an explicit CHF amount (no "full"
// sentinel). Compute the remaining refundable amount as
// total minus what's already been refunded. For a fresh
// setup-fee invoice this is just totalChf, but the formula
// is robust if admin had partially refunded earlier (rare
// but possible — same invoice could in theory get a manual
// partial refund, then a rejection).
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
if (!inv) {
throw new Error(
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
);
}
const remaining = Math.round(
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
) / 100;
if (remaining <= 0) {
refundSummary.succeeded = true; // nothing to refund — treat as success
} else {
await refundInvoice({
invoiceId: tenantRequest.setupInvoiceId,
amountChf: remaining,
reason: adminNotes
? `Tenant request rejected: ${adminNotes}`
: "Tenant request rejected",
refundedBy: user.id,
});
refundSummary.succeeded = true;
}
} catch (e: any) {
refundSummary.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
e
);
}
}
// Notify customer. Resume requests get a different email — the
// tenant already exists; copy needs to mention "stays suspended" and
// the 60-day retention deadline. Provision rejections use the
@@ -88,5 +161,6 @@ export async function POST(
return NextResponse.json({
message: "Request rejected.",
request: updated,
refund: refundSummary,
});
}