Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3131f7710 | |||
| fadfdd3435 | |||
| 427c7c6204 |
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
listRecentCronRuns,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { CronControls } from "@/components/admin/cron/cron-controls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/cron — automation dashboard.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - Last successful run of each kind, with relative time
|
||||||
|
* - Two "Run now" buttons (admin-triggered manual sweeps)
|
||||||
|
* - Recent runs table (last 30)
|
||||||
|
*
|
||||||
|
* Platform-admin gated server-side.
|
||||||
|
*/
|
||||||
|
export default async function AdminCronPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user || !user.isPlatform) redirect("/login");
|
||||||
|
const t = await getTranslations("adminCron");
|
||||||
|
|
||||||
|
const [recent, lastSuccess] = await Promise.all([
|
||||||
|
listRecentCronRuns(30),
|
||||||
|
getLastSuccessfulCronRuns(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<CronControls
|
||||||
|
initialRecent={recent}
|
||||||
|
initialLastSuccess={lastSuccess}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,12 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
{t("billingTool")}
|
{t("billingTool")}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/cron"
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cronTool")}
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/admin/openclaw"
|
href="/admin/openclaw"
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {
|
|||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
{t("currentPeriodHeading")}
|
{t("currentPeriodHeading")}
|
||||||
</h2>
|
</h2>
|
||||||
<RunningTotalWidget />
|
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||||
|
the right call-to-action vs the right hint. */}
|
||||||
|
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="animate-in animate-in-delay-2">
|
<section className="animate-in animate-in-delay-2">
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { getOrgBilling } from "@/lib/db";
|
import { getOrgBilling } from "@/lib/db";
|
||||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
* /settings/billing — customer-side billing details management.
|
||||||
*
|
*
|
||||||
* Server-side fetches the existing record (if any) and passes it to
|
* Owner-only by visibility: non-owner members get a 404 (same
|
||||||
* the client form. The form posts to PUT /api/billing on submit.
|
* response as if the page didn't exist). The link to this page
|
||||||
|
* is also hidden from non-owners on /billing and elsewhere, but
|
||||||
|
* the page itself enforces too — a non-owner who learns the URL
|
||||||
|
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||||
*
|
*
|
||||||
* Access: same gate as the API — owners and platform admins. `user`
|
* First-time visitors see an empty form. Subsequent visits see
|
||||||
* role redirects to /settings (which also wouldn't list billing for
|
* the current values, editable. Save creates or updates via the
|
||||||
* them). 403 here would be friendlier than redirect, but the most
|
* shared upsert path; the row's existence drives whether the
|
||||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
* monthly issuance cron will pick this org up.
|
||||||
* with their owner — silent redirect is gentle.
|
|
||||||
*/
|
*/
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
if (!canMutate(user)) {
|
// Non-owners get a 404 — see comment above.
|
||||||
redirect("/settings");
|
if (!user.roles.includes("owner")) notFound();
|
||||||
}
|
|
||||||
const t = await getTranslations("settingsBilling");
|
|
||||||
|
|
||||||
const billing = await getOrgBilling(user.orgId);
|
const t = await getTranslations("settingsBilling");
|
||||||
|
const existing = await getOrgBilling(user.orgId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
@@ -32,16 +33,16 @@ export default async function BillingSettingsPage() {
|
|||||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="animate-in animate-in-delay-1">
|
||||||
<BillingSettingsForm
|
<BillingSettingsForm
|
||||||
initial={billing}
|
initial={existing}
|
||||||
isPersonal={user.isPersonal}
|
isPersonal={user.isPersonal}
|
||||||
orgName={user.orgName}
|
|
||||||
userName={user.name}
|
|
||||||
userEmail={user.email}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import { runMonthlyIssuance } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* Admin-side manual trigger for the issuance sweep — same business
|
||||||
|
* logic as /api/cron/issue-monthly, different auth (session-based
|
||||||
|
* platform role check) and the option to override the target
|
||||||
|
* year/month from the request body.
|
||||||
|
*
|
||||||
|
* Body (all optional):
|
||||||
|
* { year?: number, month?: number }
|
||||||
|
*
|
||||||
|
* Default target is the previous local month — matching what the
|
||||||
|
* automated cron would do. Override is useful for catching up after
|
||||||
|
* a failed run or re-billing a past month after fixing data.
|
||||||
|
*/
|
||||||
|
const bodySchema = z.object({
|
||||||
|
year: z.number().int().min(2000).max(3000).optional(),
|
||||||
|
month: z.number().int().min(1).max(12).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(parsed.data.year && !parsed.data.month) ||
|
||||||
|
(parsed.data.month && !parsed.data.year)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "year and month must both be provided, or neither" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runMonthlyIssuance({
|
||||||
|
triggeredBy: user.id,
|
||||||
|
year: parsed.data.year,
|
||||||
|
month: parsed.data.month,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Issuance sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/admin/cron/runs/route.ts
Normal file
27
src/app/api/admin/cron/runs/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
listRecentCronRuns,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/cron/runs
|
||||||
|
*
|
||||||
|
* Returns recent cron run history plus per-kind "last successful"
|
||||||
|
* summary for the admin /admin/cron dashboard.
|
||||||
|
*
|
||||||
|
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const [recent, lastSuccess] = await Promise.all([
|
||||||
|
listRecentCronRuns(30),
|
||||||
|
getLastSuccessfulCronRuns(),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ recent, lastSuccess });
|
||||||
|
}
|
||||||
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import { runReminderSweep } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cron/send-reminders
|
||||||
|
*
|
||||||
|
* Admin-side manual trigger for the reminder sweep. Same logic
|
||||||
|
* as the machine path; session-based platform-role auth.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runReminderSweep({
|
||||||
|
triggeredBy: user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Reminder sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/cron/issue-monthly/route.ts
Normal file
42
src/app/api/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* Machine entry point for the monthly issuance sweep. Authentication
|
||||||
|
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
|
||||||
|
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
|
||||||
|
*
|
||||||
|
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
|
||||||
|
* https://app.pieced.ch/api/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* The sweep targets the calendar month that ended just before
|
||||||
|
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
|
||||||
|
* time bills May; running it on July 5th bills June; etc. The
|
||||||
|
* uniqueness constraint on (org, period_start) makes re-runs
|
||||||
|
* harmless — already-issued orgs are counted as skipped.
|
||||||
|
*
|
||||||
|
* Returns the summary {success, failure, skipped} JSON. The
|
||||||
|
* CronJob doesn't look at the response body (just the status
|
||||||
|
* code) but having a useful one helps debugging via curl.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runMonthlyIssuance({
|
||||||
|
triggeredBy: "cron",
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Issuance sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/cron/send-reminders/route.ts
Normal file
33
src/app/api/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/send-reminders
|
||||||
|
*
|
||||||
|
* Machine entry point for the daily reminder sweep. Same auth
|
||||||
|
* (bearer token in CRON_BEARER_TOKEN) and the same response
|
||||||
|
* contract as /api/cron/issue-monthly.
|
||||||
|
*
|
||||||
|
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
|
||||||
|
* past their due date and haven't received the corresponding
|
||||||
|
* reminder level yet; sends one email per invoice per run.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runReminderSweep({
|
||||||
|
triggeredBy: "cron",
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Reminder sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/app/api/settings/billing/route.ts
Normal file
85
src/app/api/settings/billing/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/billing — read the caller's org_billing row.
|
||||||
|
* Returns null if the org hasn't configured billing yet — the
|
||||||
|
* form renders empty and the PUT will create on first save.
|
||||||
|
*
|
||||||
|
* PUT /api/settings/billing — upsert the row.
|
||||||
|
*
|
||||||
|
* Authorization: caller must have role "owner" in their org.
|
||||||
|
* Non-owners get 403 (they shouldn't have reached the page UI
|
||||||
|
* anyway, which hides the link, but the API enforces too — a
|
||||||
|
* non-owner who hits this directly with curl gets refused).
|
||||||
|
*
|
||||||
|
* Personal accounts are inherently their own owner (single-user
|
||||||
|
* org), so user.roles.includes("owner") returns true and they
|
||||||
|
* can manage their own billing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const upsertSchema = z.object({
|
||||||
|
companyName: z.string().trim().min(1).max(200),
|
||||||
|
streetAddress: z.string().trim().min(1).max(200),
|
||||||
|
postalCode: z.string().trim().min(1).max(20),
|
||||||
|
city: z.string().trim().min(1).max(100),
|
||||||
|
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
|
||||||
|
country: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(2)
|
||||||
|
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
|
||||||
|
vatNumber: z.string().trim().max(40).optional().nullable(),
|
||||||
|
billingEmail: z.string().trim().email().max(200),
|
||||||
|
notes: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireOwner(user: { roles: string[] } | null) {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.roles.includes("owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!requireOwner(user as any)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const billing = await getOrgBilling(user.orgId);
|
||||||
|
return NextResponse.json({ billing });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!requireOwner(user as any)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = upsertSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const billing = await upsertOrgBilling({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
companyName: data.companyName,
|
||||||
|
streetAddress: data.streetAddress,
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country.toUpperCase(),
|
||||||
|
vatNumber: data.vatNumber ?? null,
|
||||||
|
billingEmail: data.billingEmail,
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ billing });
|
||||||
|
}
|
||||||
249
src/components/admin/cron/cron-controls.tsx
Normal file
249
src/components/admin/cron/cron-controls.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { CronRun } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialRecent: CronRun[];
|
||||||
|
initialLastSuccess: {
|
||||||
|
monthlyIssue: CronRun | null;
|
||||||
|
reminders: CronRun | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin cron dashboard. Server pre-loads `initialRecent` and
|
||||||
|
* `initialLastSuccess`; "Run now" clicks POST to the admin
|
||||||
|
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
|
||||||
|
*
|
||||||
|
* The trigger buttons disable while busy and surface the resulting
|
||||||
|
* counters inline so the admin gets immediate feedback without
|
||||||
|
* needing to scroll to the history table.
|
||||||
|
*/
|
||||||
|
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||||
|
const t = useTranslations("adminCron");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
const [recent, setRecent] = useState(initialRecent);
|
||||||
|
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
|
||||||
|
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
|
||||||
|
const [flash, setFlash] = useState<null | {
|
||||||
|
kind: "issue" | "reminders";
|
||||||
|
ok: boolean;
|
||||||
|
summary: string;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/runs");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setRecent(data.recent);
|
||||||
|
setLastSuccess(data.lastSuccess);
|
||||||
|
} catch {
|
||||||
|
// swallow — refresh is opportunistic
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerIssue = async () => {
|
||||||
|
setBusy("issue");
|
||||||
|
setFlash(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/issue-monthly", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFlash({
|
||||||
|
kind: "issue",
|
||||||
|
ok: false,
|
||||||
|
summary: j.error ?? `HTTP ${res.status}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFlash({
|
||||||
|
kind: "issue",
|
||||||
|
ok: true,
|
||||||
|
summary: t("flashIssueOk", {
|
||||||
|
success: j.successCount,
|
||||||
|
skipped: j.skippedCount,
|
||||||
|
failure: j.failureCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerReminders = async () => {
|
||||||
|
setBusy("reminders");
|
||||||
|
setFlash(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/send-reminders", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFlash({
|
||||||
|
kind: "reminders",
|
||||||
|
ok: false,
|
||||||
|
summary: j.error ?? `HTTP ${res.status}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFlash({
|
||||||
|
kind: "reminders",
|
||||||
|
ok: true,
|
||||||
|
summary: t("flashRemindersOk", {
|
||||||
|
success: j.successCount,
|
||||||
|
skipped: j.skippedCount,
|
||||||
|
failure: j.failureCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtRelative = (iso: string | null) => {
|
||||||
|
if (!iso) return t("never");
|
||||||
|
return fmt.dateTime(new Date(iso), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 6: surface failures prominently. Any run in the recent
|
||||||
|
// window with a non-zero failure_count drives a top-of-page
|
||||||
|
// banner — the row in the table is already red, but a banner
|
||||||
|
// means the admin doesn't have to scroll to notice.
|
||||||
|
const recentFailures = recent.filter((r) => r.failureCount > 0);
|
||||||
|
const hasRecentFailures = recentFailures.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{hasRecentFailures && (
|
||||||
|
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||||
|
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t("failureBannerBody", { count: recentFailures.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
||||||
|
{t("monthlyIssue")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-secondary mb-1">
|
||||||
|
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">
|
||||||
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={triggerIssue}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy === "issue" ? t("running") : t("runIssueNow")}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
||||||
|
{t("reminders")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-secondary mb-1">
|
||||||
|
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">
|
||||||
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={triggerReminders}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{flash && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md border text-sm ${
|
||||||
|
flash.ok
|
||||||
|
? "border-success bg-success/10 text-success"
|
||||||
|
: "border-error bg-error/10 text-error"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{flash.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("recentRuns")}
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted italic py-4">
|
||||||
|
{t("noRunsYet")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("startedCol")}</th>
|
||||||
|
<th className="pb-2">{t("kindCol")}</th>
|
||||||
|
<th className="pb-2">{t("triggeredByCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("okCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("skipCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("failCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={`border-t border-border align-top ${
|
||||||
|
r.failureCount > 0 ? "bg-error/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 text-xs font-mono">
|
||||||
|
{fmtRelative(r.startedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs">
|
||||||
|
{t(`kind.${r.runKind}` as any)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary font-mono">
|
||||||
|
{r.triggeredBy === "cron"
|
||||||
|
? t("triggeredByCron")
|
||||||
|
: r.triggeredBy.slice(0, 8) + "…"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs text-success">
|
||||||
|
{r.successCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs text-text-secondary">
|
||||||
|
{r.skippedCount}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2 text-right font-mono text-xs ${
|
||||||
|
r.failureCount > 0 ? "text-error" : "text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.failureCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,17 @@ type CurrentResponse =
|
|||||||
| { draft: InvoiceDraft }
|
| { draft: InvoiceDraft }
|
||||||
| { error: string; code?: string };
|
| { error: string; code?: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Whether the viewing user has org-owner role. Drives the
|
||||||
|
* "complete your billing details" CTA — only owners can edit
|
||||||
|
* billing settings, so non-owners see a softer message asking
|
||||||
|
* them to contact their org owner instead. The flag is computed
|
||||||
|
* server-side and passed in to avoid a second API round-trip.
|
||||||
|
*/
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live running total for the current calendar month.
|
* Live running total for the current calendar month.
|
||||||
*
|
*
|
||||||
@@ -28,7 +39,7 @@ type CurrentResponse =
|
|||||||
* No polling — the page is static enough that an explicit
|
* No polling — the page is static enough that an explicit
|
||||||
* "refresh" link is good enough if the user wants newer numbers.
|
* "refresh" link is good enough if the user wants newer numbers.
|
||||||
*/
|
*/
|
||||||
export function RunningTotalWidget() {
|
export function RunningTotalWidget({ isOwner }: Props) {
|
||||||
const t = useTranslations("customerBilling");
|
const t = useTranslations("customerBilling");
|
||||||
const fmt = useFormatter();
|
const fmt = useFormatter();
|
||||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||||
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!data || "error" in data) {
|
if (!data || "error" in data) {
|
||||||
|
const noConfig =
|
||||||
|
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-text-secondary py-2">
|
<p className="text-sm text-text-secondary py-2">
|
||||||
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||||
? t("noBillingConfig")
|
|
||||||
: t("currentPeriodError")}
|
|
||||||
</p>
|
</p>
|
||||||
|
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
|
||||||
|
settings, so we show them a "contact owner" hint instead
|
||||||
|
— that's gentler than a button that 404s on click. */}
|
||||||
|
{noConfig && isOwner && (
|
||||||
|
<Link
|
||||||
|
href="/settings/billing"
|
||||||
|
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("configureBillingCta")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{noConfig && !isOwner && (
|
||||||
|
<p className="text-xs text-text-muted italic mt-2">
|
||||||
|
{t("noBillingConfigNonOwner")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
247
src/components/settings/billing-form.tsx
Normal file
247
src/components/settings/billing-form.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial: OrgBilling | null;
|
||||||
|
/**
|
||||||
|
* Personal-account (individual customer) flag from the session.
|
||||||
|
* Individuals get a "Full name" field instead of "Company name",
|
||||||
|
* and the VAT input is hidden entirely — they don't have one and
|
||||||
|
* showing the field would only confuse. The underlying column is
|
||||||
|
* still `company_name` in the DB and the invoice PDF; for an
|
||||||
|
* individual that field carries their full name, which is
|
||||||
|
* exactly what should print on the invoice.
|
||||||
|
*/
|
||||||
|
isPersonal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer billing settings form. Drives PUT /api/settings/billing
|
||||||
|
* which upserts org_billing for the caller's org.
|
||||||
|
*
|
||||||
|
* Validation is the same regex as the server-side zod schema for
|
||||||
|
* the country field (ISO 3166-1 alpha-2). Other fields are checked
|
||||||
|
* for required + max-length client-side; the server is the
|
||||||
|
* authority and re-validates everything.
|
||||||
|
*
|
||||||
|
* On success we router.refresh() the page so the server component
|
||||||
|
* re-fetches and any "create now" -> "edit" wording flips.
|
||||||
|
*/
|
||||||
|
export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||||
|
const t = useTranslations("settingsBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: initial?.companyName ?? "",
|
||||||
|
streetAddress: initial?.streetAddress ?? "",
|
||||||
|
postalCode: initial?.postalCode ?? "",
|
||||||
|
city: initial?.city ?? "",
|
||||||
|
country: initial?.country ?? "CH",
|
||||||
|
vatNumber: initial?.vatNumber ?? "",
|
||||||
|
billingEmail: initial?.billingEmail ?? "",
|
||||||
|
notes: initial?.notes ?? "",
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState(false);
|
||||||
|
|
||||||
|
const set =
|
||||||
|
(field: keyof typeof form) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSavedFlash(false);
|
||||||
|
// Client-side gate on required fields — the server re-validates.
|
||||||
|
if (
|
||||||
|
!form.companyName.trim() ||
|
||||||
|
!form.streetAddress.trim() ||
|
||||||
|
!form.postalCode.trim() ||
|
||||||
|
!form.city.trim() ||
|
||||||
|
!form.country.trim() ||
|
||||||
|
!form.billingEmail.trim()
|
||||||
|
) {
|
||||||
|
setError(t("missingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
|
||||||
|
setError(t("invalidCountry"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
|
||||||
|
setError(t("invalidEmail"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/billing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
streetAddress: form.streetAddress.trim(),
|
||||||
|
postalCode: form.postalCode.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
country: form.country.trim().toUpperCase(),
|
||||||
|
// Personal accounts never have a VAT number — force null
|
||||||
|
// regardless of stale state, in case a value was stored
|
||||||
|
// before the account got flagged as personal.
|
||||||
|
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
|
||||||
|
billingEmail: form.billingEmail.trim(),
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setSavedFlash(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.companyName}
|
||||||
|
onChange={set("companyName")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("streetAddressLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.streetAddress}
|
||||||
|
onChange={set("streetAddress")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Field label={t("postalCodeLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("cityLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={t("countryLabel")}
|
||||||
|
required
|
||||||
|
hint={t("countryHint")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
country: e.target.value.toUpperCase().slice(0, 2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
maxLength={2}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{!isPersonal && (
|
||||||
|
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.vatNumber}
|
||||||
|
onChange={set("vatNumber")}
|
||||||
|
maxLength={40}
|
||||||
|
placeholder="CHE-123.456.789 MWST"
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.billingEmail}
|
||||||
|
onChange={set("billingEmail")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("notesLabel")} hint={t("notesHint")}>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={set("notes")}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
{savedFlash && (
|
||||||
|
<p className="text-sm text-success">{t("saved")}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && (
|
||||||
|
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
src/lib/cron.ts
Normal file
360
src/lib/cron.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Phase 5 — Automated billing cron logic.
|
||||||
|
*
|
||||||
|
* This module hosts the two sweeps:
|
||||||
|
* - runMonthlyIssuance() — invoked monthly to generate invoices
|
||||||
|
* for orgs opted into auto-issuance. Idempotent via the
|
||||||
|
* uniq_invoices_org_period constraint on invoices: a re-run
|
||||||
|
* for an org that's already been billed for the target period
|
||||||
|
* gets caught as a duplicate and counted as a skip, not a
|
||||||
|
* failure.
|
||||||
|
* - runReminderSweep() — invoked daily. Walks open/overdue
|
||||||
|
* invoices, sends the appropriate reminder level (1/2/3) once
|
||||||
|
* per invoice via the invoice_reminders unique-key constraint.
|
||||||
|
*
|
||||||
|
* Both entry points return a summary {success, failure, skipped}
|
||||||
|
* that the caller persists via finishCronRun(). The shared
|
||||||
|
* structure means the HTTP routes (machine + admin variants) are
|
||||||
|
* trivial wrappers.
|
||||||
|
*
|
||||||
|
* Time-of-month math is timezone-aware: we read the calendar in
|
||||||
|
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
|
||||||
|
* at 00:30 local time on the 1st — UTC at that moment is still in
|
||||||
|
* the previous month, and a naive `getUTCMonth() - 1` would bill
|
||||||
|
* the wrong period.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
finishCronRun,
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
getOrgBilling,
|
||||||
|
getReminderLevelsSent,
|
||||||
|
listAutoIssueOrgIds,
|
||||||
|
listInvoicesPendingReminders,
|
||||||
|
recordReminderSent,
|
||||||
|
startCronRun,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "./db";
|
||||||
|
import { generateInvoice } from "./billing";
|
||||||
|
import { sendInvoiceReminderEmail } from "./email";
|
||||||
|
|
||||||
|
// The org_billing snapshot's company_name field doubles as the
|
||||||
|
// recipient name when no separate "billing contact" exists in
|
||||||
|
// our schema. Same convention as Phase 3's issuance email.
|
||||||
|
|
||||||
|
// All cron timing assumes Switzerland's calendar — the operator,
|
||||||
|
// the customers, and the legal basis (Swiss MWST) are all here.
|
||||||
|
const TZ = "Europe/Zurich";
|
||||||
|
|
||||||
|
export type CronSummary = {
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorDetails: Array<{
|
||||||
|
orgId?: string;
|
||||||
|
invoiceId?: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monthly issuance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (year, month) of the calendar month that ended JUST BEFORE
|
||||||
|
* `now` in the configured timezone. This is what the issuance
|
||||||
|
* sweep bills.
|
||||||
|
*
|
||||||
|
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
|
||||||
|
* when the sweep runs at 00:30 Zurich and UTC is still in the
|
||||||
|
* previous month.
|
||||||
|
*/
|
||||||
|
export function previousLocalMonth(
|
||||||
|
now: Date = new Date()
|
||||||
|
): { year: number; month: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(now);
|
||||||
|
const year = Number(parts.find((p) => p.type === "year")!.value);
|
||||||
|
const month = Number(parts.find((p) => p.type === "month")!.value);
|
||||||
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
return { year, month: month - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMonthlyIssuance(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
/** Override target year/month — defaults to previous local month. */
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const target =
|
||||||
|
opts.year && opts.month
|
||||||
|
? { year: opts.year, month: opts.month }
|
||||||
|
: previousLocalMonth();
|
||||||
|
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = await listAutoIssueOrgIds();
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
try {
|
||||||
|
const orgBilling = await getOrgBilling(orgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
// Auto-issue is enabled but billing details are missing.
|
||||||
|
// Skip rather than fail — the admin needs to complete the
|
||||||
|
// address before invoicing can succeed.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
orgId,
|
||||||
|
reason: "org_billing not configured",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Derive invoice locale from the org's country. PieCed is
|
||||||
|
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
|
||||||
|
// FR/BE/LU customers get French, IT customers get Italian,
|
||||||
|
// anything else falls through to English. Customers needing
|
||||||
|
// a different locale can still trigger a manual issuance
|
||||||
|
// with an explicit override from the admin UI.
|
||||||
|
const locale = pickLocaleForCountry(orgBilling.country);
|
||||||
|
const { invoice } = await generateInvoice({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
year: target.year,
|
||||||
|
month: target.month,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
if (invoice) {
|
||||||
|
summary.successCount += 1;
|
||||||
|
} else {
|
||||||
|
// dryRun path — shouldn't happen in production. Defensive.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// The uniqueness constraint on (zitadel_org_id, period_start)
|
||||||
|
// surfaces as "An invoice already exists for this org and
|
||||||
|
// billing period" from createInvoice. Re-running the cron
|
||||||
|
// mid-month or after a partial completion is therefore safe:
|
||||||
|
// already-billed orgs end up as skipped, not failed.
|
||||||
|
const msg = String(e?.message ?? e);
|
||||||
|
const isAlreadyIssued = /already exists for this org and billing period/i.test(
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
if (isAlreadyIssued) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
} else {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({ orgId, reason: msg });
|
||||||
|
console.error(
|
||||||
|
`runMonthlyIssuance: org ${orgId} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
// Catastrophic — the sweep itself failed (DB down, etc).
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder sweep
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reminder level (if any) is due now for this invoice?
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
|
||||||
|
* - else days_past_due >= 14 AND level 2 not yet sent → 2
|
||||||
|
* - else days_past_due >= 7 AND level 1 not yet sent → 1
|
||||||
|
* - else → null (nothing to do this run)
|
||||||
|
*
|
||||||
|
* One reminder per cron run per invoice — highest applicable
|
||||||
|
* un-sent level wins. If a customer fell behind quickly and is
|
||||||
|
* already 35 days past due without ever having received levels
|
||||||
|
* 1 or 2 (e.g. the cron was broken for a while), they get level
|
||||||
|
* 3 directly. We don't backfill lower levels.
|
||||||
|
*/
|
||||||
|
function nextReminderLevel(
|
||||||
|
daysPastDue: number,
|
||||||
|
sent: Set<number>
|
||||||
|
): 1 | 2 | 3 | null {
|
||||||
|
if (daysPastDue >= 30 && !sent.has(3)) return 3;
|
||||||
|
if (daysPastDue >= 14 && !sent.has(2)) return 2;
|
||||||
|
if (daysPastDue >= 7 && !sent.has(1)) return 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(later: Date, earlier: Date): number {
|
||||||
|
const ms = later.getTime() - earlier.getTime();
|
||||||
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a default invoice locale based on the org's country
|
||||||
|
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
|
||||||
|
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
|
||||||
|
* FR/BE/LU get French, IT gets Italian, anything else falls
|
||||||
|
* through to English.
|
||||||
|
*
|
||||||
|
* This only drives the automated issuance default. Manual
|
||||||
|
* issuance from the admin UI takes an explicit override.
|
||||||
|
*/
|
||||||
|
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
|
||||||
|
const c = country.toUpperCase();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||||
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||||
|
if (c === "IT") return "it";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReminderSweep(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const runId = await startCronRun("reminders", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Flip stale 'open' → 'overdue' first so the listing reflects
|
||||||
|
// current status, and audit trails stay accurate.
|
||||||
|
await syncOverdueInvoices().catch((e) => {
|
||||||
|
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = await listInvoicesPendingReminders();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const inv of candidates) {
|
||||||
|
try {
|
||||||
|
const sent = await getReminderLevelsSent(inv.id);
|
||||||
|
const dueAt = new Date(inv.dueAt);
|
||||||
|
const days = daysBetween(now, dueAt);
|
||||||
|
const level = nextReminderLevel(days, sent);
|
||||||
|
if (level === null) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const billing = inv.billingSnapshot;
|
||||||
|
if (!billing.billingEmail) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: "no billing email on snapshot",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
|
||||||
|
"de", "en", "fr", "it",
|
||||||
|
];
|
||||||
|
const locale = supportedLocales.includes(inv.locale as any)
|
||||||
|
? (inv.locale as "de" | "en" | "fr" | "it")
|
||||||
|
: "de";
|
||||||
|
|
||||||
|
await sendInvoiceReminderEmail({
|
||||||
|
to: billing.billingEmail,
|
||||||
|
contactName: billing.companyName,
|
||||||
|
companyName: billing.companyName,
|
||||||
|
invoiceNumber: inv.invoiceNumber,
|
||||||
|
totalChf: inv.totalChf,
|
||||||
|
currency: "CHF",
|
||||||
|
dueAt: inv.dueAt,
|
||||||
|
daysPastDue: days,
|
||||||
|
level,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
// Record AFTER the send. If the SMTP send fails the email
|
||||||
|
// helper logs and doesn't throw, so we'd still record — but
|
||||||
|
// that's a tradeoff we accept: at-least-once delivery semantics
|
||||||
|
// with logged warnings is better than at-most-once where a
|
||||||
|
// transient failure stops the customer from ever getting
|
||||||
|
// reminded. If duplicate-reminder fatigue becomes a real
|
||||||
|
// problem in production, switch to: send first, only record
|
||||||
|
// on confirmed transporter success.
|
||||||
|
await recordReminderSent({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
level,
|
||||||
|
sentBy: opts.triggeredBy,
|
||||||
|
emailSentTo: billing.billingEmail,
|
||||||
|
});
|
||||||
|
summary.successCount += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: String(e?.message ?? e),
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`runReminderSweep: invoice ${inv.id} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth — bearer token for the machine endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
|
||||||
|
* is injected from OpenBao via the portal-cron K8s Secret. Both
|
||||||
|
* the CronJob and the portal Deployment reference it; the
|
||||||
|
* CronJob sends it in the Authorization header, the portal checks
|
||||||
|
* with timing-safe equals to defeat character-by-character probing.
|
||||||
|
*/
|
||||||
|
export function verifyCronBearer(authHeader: string | null): boolean {
|
||||||
|
if (!authHeader) return false;
|
||||||
|
const expected = process.env.CRON_BEARER_TOKEN;
|
||||||
|
if (!expected || expected.length < 16) {
|
||||||
|
// Treat misconfiguration as a hard refusal so a missing/
|
||||||
|
// accidentally-empty token doesn't silently grant access.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!authHeader.startsWith("Bearer ")) return false;
|
||||||
|
const got = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (got.length !== expected.length) return false;
|
||||||
|
// Constant-time byte compare. Node's Buffer.compare and the
|
||||||
|
// crypto.timingSafeEqual function both work, but the latter
|
||||||
|
// throws on length mismatch; the length pre-check above
|
||||||
|
// protects against that.
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < got.length; i++) {
|
||||||
|
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for the admin UI to render "last run X ago" indicators.
|
||||||
|
export { getLastSuccessfulCronRuns };
|
||||||
214
src/lib/db.ts
214
src/lib/db.ts
@@ -598,6 +598,26 @@ const MIGRATION_SQL = `
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
|
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
|
||||||
ON stripe_events (event_type, received_at DESC);
|
ON stripe_events (event_type, received_at DESC);
|
||||||
|
|
||||||
|
-- Phase 5: Cron run history. One row per invocation of either the
|
||||||
|
-- monthly issuance sweep or the daily reminder sweep, regardless of
|
||||||
|
-- whether it ran from K8s CronJob or an admin's manual trigger.
|
||||||
|
-- The summary counters let the admin UI render "last run: 12 issued,
|
||||||
|
-- 0 failed" without joining against invoices/reminders. Detail rows
|
||||||
|
-- live in the JSONB error_details on failure for diagnosis.
|
||||||
|
CREATE TABLE IF NOT EXISTS cron_run_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
run_kind TEXT NOT NULL CHECK (run_kind IN ('monthly_issue','reminders')),
|
||||||
|
triggered_by TEXT NOT NULL, -- 'cron' or '<admin-user-id>'
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
success_count INT NOT NULL DEFAULT 0,
|
||||||
|
failure_count INT NOT NULL DEFAULT 0,
|
||||||
|
skipped_count INT NOT NULL DEFAULT 0,
|
||||||
|
error_details JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cron_run_history_kind_started
|
||||||
|
ON cron_run_history (run_kind, started_at DESC);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let migrated = false;
|
let migrated = false;
|
||||||
@@ -2961,3 +2981,197 @@ export async function setInvoiceStripePaymentIntent(
|
|||||||
[invoiceId, paymentIntentId]
|
[invoiceId, paymentIntentId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 5 — Cron run history + reminder helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { CronRun, CronRunKind } from "@/types";
|
||||||
|
|
||||||
|
function rowToCronRun(row: any): CronRun {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
runKind: row.run_kind,
|
||||||
|
triggeredBy: row.triggered_by,
|
||||||
|
startedAt:
|
||||||
|
row.started_at?.toISOString?.() ?? String(row.started_at),
|
||||||
|
finishedAt: row.finished_at
|
||||||
|
? row.finished_at.toISOString?.() ?? String(row.finished_at)
|
||||||
|
: null,
|
||||||
|
successCount: Number(row.success_count ?? 0),
|
||||||
|
failureCount: Number(row.failure_count ?? 0),
|
||||||
|
skippedCount: Number(row.skipped_count ?? 0),
|
||||||
|
errorDetails: row.error_details ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a new cron-run row in 'started' state. Returns the row's
|
||||||
|
* id which the caller passes to finishCronRun() with the summary
|
||||||
|
* stats once the sweep completes.
|
||||||
|
*
|
||||||
|
* Separating start/finish lets the admin UI distinguish an in-
|
||||||
|
* progress run from a finished one, and lets a crashed pod leave
|
||||||
|
* a forensic trace ("started but never finished — investigate").
|
||||||
|
*/
|
||||||
|
export async function startCronRun(
|
||||||
|
runKind: CronRunKind,
|
||||||
|
triggeredBy: string
|
||||||
|
): Promise<string> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`INSERT INTO cron_run_history (run_kind, triggered_by)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id`,
|
||||||
|
[runKind, triggeredBy]
|
||||||
|
);
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishCronRun(
|
||||||
|
id: string,
|
||||||
|
summary: {
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorDetails?: unknown;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query(
|
||||||
|
`UPDATE cron_run_history
|
||||||
|
SET finished_at = now(),
|
||||||
|
success_count = $2,
|
||||||
|
failure_count = $3,
|
||||||
|
skipped_count = $4,
|
||||||
|
error_details = $5::jsonb
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
summary.successCount,
|
||||||
|
summary.failureCount,
|
||||||
|
summary.skippedCount,
|
||||||
|
summary.errorDetails ? JSON.stringify(summary.errorDetails) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecentCronRuns(
|
||||||
|
limit = 30
|
||||||
|
): Promise<CronRun[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT * FROM cron_run_history
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToCronRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent successful run of each kind. Drives the admin
|
||||||
|
* dashboard's "last issuance: N days ago" indicator. Returns
|
||||||
|
* null for a kind that has never run successfully.
|
||||||
|
*/
|
||||||
|
export async function getLastSuccessfulCronRuns(): Promise<{
|
||||||
|
monthlyIssue: CronRun | null;
|
||||||
|
reminders: CronRun | null;
|
||||||
|
}> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT DISTINCT ON (run_kind) *
|
||||||
|
FROM cron_run_history
|
||||||
|
WHERE finished_at IS NOT NULL AND failure_count = 0
|
||||||
|
ORDER BY run_kind, started_at DESC`
|
||||||
|
);
|
||||||
|
const map: Record<string, CronRun> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
map[row.run_kind] = rowToCronRun(row);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
monthlyIssue: map["monthly_issue"] ?? null,
|
||||||
|
reminders: map["reminders"] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs of all orgs with auto-issue enabled. Drives the monthly
|
||||||
|
* issuance sweep. Returns just the zitadel_org_id strings — the
|
||||||
|
* caller fetches OrgBilling per-org during the sweep so a bad
|
||||||
|
* row doesn't poison the whole list at SELECT time.
|
||||||
|
*/
|
||||||
|
export async function listAutoIssueOrgIds(): Promise<string[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT zitadel_org_id FROM org_billing_config
|
||||||
|
WHERE auto_invoice_enabled = TRUE`
|
||||||
|
);
|
||||||
|
return result.rows.map((r) => r.zitadel_org_id as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open or overdue invoices whose org has auto-reminders enabled
|
||||||
|
* and whose due_at is at least 7 days in the past. The reminder
|
||||||
|
* sweep takes this list and picks the right level (1/2/3) per
|
||||||
|
* invoice based on days-past-due AND which levels have already
|
||||||
|
* been sent.
|
||||||
|
*
|
||||||
|
* We don't filter by "needs reminder X yet" in SQL because the
|
||||||
|
* level logic is more readable in TypeScript and the candidate
|
||||||
|
* set is small (only past-due invoices for opted-in orgs).
|
||||||
|
*/
|
||||||
|
export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT ${INVOICE_LIST_COLUMNS}
|
||||||
|
FROM invoices i
|
||||||
|
JOIN org_billing_config c
|
||||||
|
ON c.zitadel_org_id = i.zitadel_org_id
|
||||||
|
AND c.auto_reminders_enabled = TRUE
|
||||||
|
WHERE i.status IN ('open','overdue')
|
||||||
|
AND i.due_at < now() - INTERVAL '7 days'
|
||||||
|
ORDER BY i.due_at ASC`
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToInvoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reminder levels have already been sent for this invoice?
|
||||||
|
* Returns a Set of {1, 2, 3} subset. Drives the "send the next
|
||||||
|
* level only" logic in the reminder sweep.
|
||||||
|
*/
|
||||||
|
export async function getReminderLevelsSent(
|
||||||
|
invoiceId: string
|
||||||
|
): Promise<Set<number>> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT level FROM invoice_reminders WHERE invoice_id = $1`,
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
return new Set(result.rows.map((r) => Number(r.level)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a reminder as sent. Wrapped in an INSERT ... ON CONFLICT
|
||||||
|
* DO NOTHING so a retry of the same level after a partial failure
|
||||||
|
* is a no-op rather than a 23505 explosion. Returns true if a row
|
||||||
|
* was inserted (first send), false on conflict (already sent).
|
||||||
|
*/
|
||||||
|
export async function recordReminderSent(params: {
|
||||||
|
invoiceId: string;
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
sentBy: string;
|
||||||
|
emailSentTo: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`INSERT INTO invoice_reminders
|
||||||
|
(invoice_id, level, sent_by, email_sent_to)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (invoice_id, level) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[params.invoiceId, params.level, params.sentBy, params.emailSentTo]
|
||||||
|
);
|
||||||
|
return result.rowCount === 1;
|
||||||
|
}
|
||||||
|
|||||||
144
src/lib/email.ts
144
src/lib/email.ts
@@ -1014,3 +1014,147 @@ export async function sendInvoiceIssuedEmail(params: {
|
|||||||
console.error("Failed to send invoice issued email:", err);
|
console.error("Failed to send invoice issued email:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder emails — Phase 5
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a payment reminder for an open/overdue invoice.
|
||||||
|
*
|
||||||
|
* Three escalation levels:
|
||||||
|
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
|
||||||
|
* you missed it".
|
||||||
|
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
|
||||||
|
* outstanding, please pay.
|
||||||
|
* 3 — Final notice: ~30 days past due. Explicit consequences
|
||||||
|
* (service may be suspended). Last automated touch — beyond
|
||||||
|
* this, admin involvement is expected.
|
||||||
|
*
|
||||||
|
* Failure is logged, never thrown — the cron sweep must continue
|
||||||
|
* past a single failed send.
|
||||||
|
*/
|
||||||
|
export async function sendInvoiceReminderEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string;
|
||||||
|
dueAt: string;
|
||||||
|
daysPastDue: number;
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
// Per-locale strings keyed by the three escalation levels.
|
||||||
|
// Kept inline (rather than the next-intl message files) because
|
||||||
|
// the email layer doesn't import from React's i18n context.
|
||||||
|
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
|
||||||
|
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
|
||||||
|
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
|
||||||
|
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
|
||||||
|
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
|
||||||
|
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
|
||||||
|
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
|
||||||
|
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
|
||||||
|
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
|
||||||
|
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
|
||||||
|
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
|
||||||
|
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
|
||||||
|
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
|
||||||
|
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
|
||||||
|
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
|
||||||
|
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
|
||||||
|
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const LABELS: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
|
||||||
|
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
|
||||||
|
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
|
||||||
|
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
|
||||||
|
};
|
||||||
|
const l = LABELS[L];
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
// Final-notice gets red accent; earlier levels keep the brand green.
|
||||||
|
const accent = params.level === 3 ? "#dc2626" : "#10B981";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: SUBJECTS[L][params.level],
|
||||||
|
text: [
|
||||||
|
`${l.greeting} ${params.contactName},`,
|
||||||
|
"",
|
||||||
|
INTROS[L][params.level],
|
||||||
|
"",
|
||||||
|
`${l.num}: ${params.invoiceNumber}`,
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
`${l.days}: ${params.daysPastDue}`,
|
||||||
|
"",
|
||||||
|
`${l.cta}: ${link}`,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
|
||||||
|
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
|
||||||
|
<p>${l.greeting} ${safeName},</p>
|
||||||
|
<p>${escapeHtml(INTROS[L][params.level])}</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||||
|
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
|
||||||
|
<p style="color:#666;font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -393,7 +393,8 @@
|
|||||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||||
"openclawTool": "OpenClaw-Versionen",
|
"openclawTool": "OpenClaw-Versionen",
|
||||||
"billingTool": "Abrechnung →",
|
"billingTool": "Abrechnung →",
|
||||||
"skillsQueueTool": "Aktivierungs-Warteschlange"
|
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
||||||
|
"cronTool": "Automatisierung"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Autorisierte Benutzer",
|
"title": "Autorisierte Benutzer",
|
||||||
@@ -481,25 +482,29 @@
|
|||||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
|
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
|
||||||
},
|
},
|
||||||
"settingsBilling": {
|
"settingsBilling": {
|
||||||
"title": "Abrechnung",
|
"title": "Rechnungsdaten",
|
||||||
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
|
"subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
|
||||||
"companyName": "Firmenname",
|
"companyNameLabel": "Firmenname",
|
||||||
"streetAddress": "Strasse",
|
"streetAddressLabel": "Strasse und Hausnummer",
|
||||||
"postalCode": "PLZ",
|
"postalCodeLabel": "PLZ",
|
||||||
"city": "Ort",
|
"cityLabel": "Ort",
|
||||||
"country": "Land",
|
"countryLabel": "Ländercode",
|
||||||
"vatNumber": "MWST-Nummer",
|
"countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
|
||||||
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
|
"vatNumberLabel": "MWST-Nummer (optional)",
|
||||||
"billingEmail": "Rechnungs-E-Mail",
|
"vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
|
||||||
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
|
"billingEmailLabel": "Rechnungs-E-Mail",
|
||||||
"notes": "Notizen",
|
"billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
|
||||||
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss – MWST-Befreiung, besondere Rechnungsstellung usw.",
|
"notesLabel": "Bemerkungen (optional)",
|
||||||
"save": "Speichern",
|
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
|
"createBilling": "Rechnungsdaten speichern",
|
||||||
|
"saving": "Speichern…",
|
||||||
"saved": "Gespeichert.",
|
"saved": "Gespeichert.",
|
||||||
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||||
"lastUpdated": "Zuletzt aktualisiert {when}",
|
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||||
"fullName": "Voller Name",
|
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||||
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
"fullNameLabel": "Vor- und Nachname",
|
||||||
|
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -741,6 +746,40 @@
|
|||||||
"payWithCard": "Mit Karte bezahlen",
|
"payWithCard": "Mit Karte bezahlen",
|
||||||
"redirectingToStripe": "Weiterleitung…",
|
"redirectingToStripe": "Weiterleitung…",
|
||||||
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
||||||
"paymentCancelled": "Zahlung abgebrochen."
|
"paymentCancelled": "Zahlung abgebrochen.",
|
||||||
|
"configureBillingCta": "Rechnungsdaten einrichten",
|
||||||
|
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
|
||||||
|
},
|
||||||
|
"adminCron": {
|
||||||
|
"title": "Abrechnungsautomatisierung",
|
||||||
|
"subtitle": "Monatliche Rechnungsstellung und tägliche Mahnungsläufe. Beides läuft automatisch; mit den Schaltflächen unten können Sie einen Lauf manuell auslösen.",
|
||||||
|
"monthlyIssue": "Monatliche Rechnungsstellung",
|
||||||
|
"reminders": "Mahnungen",
|
||||||
|
"scheduleIssueLabel": "Zeitplan",
|
||||||
|
"scheduleIssueValue": "00:30 Europe/Zurich am 1.",
|
||||||
|
"scheduleReminderLabel": "Zeitplan",
|
||||||
|
"scheduleReminderValue": "09:00 Europe/Zurich täglich",
|
||||||
|
"lastSuccess": "Letzter Erfolg",
|
||||||
|
"never": "nie",
|
||||||
|
"runIssueNow": "Letzten Monat jetzt abrechnen",
|
||||||
|
"runRemindersNow": "Mahnungslauf jetzt starten",
|
||||||
|
"running": "Läuft…",
|
||||||
|
"flashIssueOk": "Rechnungsstellung abgeschlossen: {success} Rechnungen erstellt, {skipped} übersprungen, {failure} fehlgeschlagen.",
|
||||||
|
"flashRemindersOk": "Mahnungen versendet: {success} erfolgreich, {skipped} übersprungen, {failure} fehlgeschlagen.",
|
||||||
|
"recentRuns": "Letzte Läufe (max. 30)",
|
||||||
|
"noRunsYet": "Noch keine Automatisierungsläufe erfasst.",
|
||||||
|
"startedCol": "Gestartet",
|
||||||
|
"kindCol": "Art",
|
||||||
|
"triggeredByCol": "Ausgelöst von",
|
||||||
|
"okCol": "OK",
|
||||||
|
"skipCol": "Übersprungen",
|
||||||
|
"failCol": "Fehler",
|
||||||
|
"triggeredByCron": "Cron",
|
||||||
|
"kind": {
|
||||||
|
"monthly_issue": "Rechnungsstellung",
|
||||||
|
"reminders": "Mahnungen"
|
||||||
|
},
|
||||||
|
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
|
||||||
|
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,8 @@
|
|||||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||||
"openclawTool": "OpenClaw versions",
|
"openclawTool": "OpenClaw versions",
|
||||||
"billingTool": "Billing →",
|
"billingTool": "Billing →",
|
||||||
"skillsQueueTool": "Activation Queue"
|
"skillsQueueTool": "Activation Queue",
|
||||||
|
"cronTool": "Automation"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Authorized Users",
|
"title": "Authorized Users",
|
||||||
@@ -481,25 +482,29 @@
|
|||||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
|
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
|
||||||
},
|
},
|
||||||
"settingsBilling": {
|
"settingsBilling": {
|
||||||
"title": "Billing",
|
"title": "Billing details",
|
||||||
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
|
"subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
|
||||||
"companyName": "Company name",
|
"companyNameLabel": "Company name",
|
||||||
"streetAddress": "Street address",
|
"streetAddressLabel": "Street address",
|
||||||
"postalCode": "Postal code",
|
"postalCodeLabel": "Postal code",
|
||||||
"city": "City",
|
"cityLabel": "City",
|
||||||
"country": "Country",
|
"countryLabel": "Country code",
|
||||||
"vatNumber": "VAT number",
|
"countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
|
||||||
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
|
"vatNumberLabel": "VAT number (optional)",
|
||||||
"billingEmail": "Billing email",
|
"vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
|
||||||
"billingEmailHelp": "Where invoices and billing communication will be sent.",
|
"billingEmailLabel": "Billing email",
|
||||||
"notes": "Notes",
|
"billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
|
||||||
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
|
"notesLabel": "Notes (optional)",
|
||||||
"save": "Save",
|
"notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"createBilling": "Save billing details",
|
||||||
|
"saving": "Saving…",
|
||||||
"saved": "Saved.",
|
"saved": "Saved.",
|
||||||
"saveFailed": "Could not save. Please try again.",
|
"missingRequired": "Please fill in all required fields.",
|
||||||
"lastUpdated": "Last updated {when}",
|
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
|
||||||
"fullName": "Full name",
|
"invalidEmail": "Please enter a valid email address.",
|
||||||
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
"fullNameLabel": "Full name",
|
||||||
|
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -741,6 +746,40 @@
|
|||||||
"payWithCard": "Pay with card",
|
"payWithCard": "Pay with card",
|
||||||
"redirectingToStripe": "Redirecting…",
|
"redirectingToStripe": "Redirecting…",
|
||||||
"paymentReceived": "Payment received — thank you!",
|
"paymentReceived": "Payment received — thank you!",
|
||||||
"paymentCancelled": "Payment cancelled."
|
"paymentCancelled": "Payment cancelled.",
|
||||||
|
"configureBillingCta": "Configure billing details",
|
||||||
|
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
|
||||||
|
},
|
||||||
|
"adminCron": {
|
||||||
|
"title": "Billing automation",
|
||||||
|
"subtitle": "Monthly issuance and daily reminder sweeps. Both run automatically; use the buttons below to trigger a sweep on demand.",
|
||||||
|
"monthlyIssue": "Monthly issuance",
|
||||||
|
"reminders": "Reminders",
|
||||||
|
"scheduleIssueLabel": "Schedule",
|
||||||
|
"scheduleIssueValue": "00:30 Europe/Zurich on the 1st",
|
||||||
|
"scheduleReminderLabel": "Schedule",
|
||||||
|
"scheduleReminderValue": "09:00 Europe/Zurich daily",
|
||||||
|
"lastSuccess": "Last success",
|
||||||
|
"never": "never",
|
||||||
|
"runIssueNow": "Run last month's issuance now",
|
||||||
|
"runRemindersNow": "Run reminder sweep now",
|
||||||
|
"running": "Running…",
|
||||||
|
"flashIssueOk": "Issuance complete: {success} invoices issued, {skipped} skipped, {failure} failed.",
|
||||||
|
"flashRemindersOk": "Reminders sent: {success} succeeded, {skipped} skipped, {failure} failed.",
|
||||||
|
"recentRuns": "Recent runs (last 30)",
|
||||||
|
"noRunsYet": "No automation runs recorded yet.",
|
||||||
|
"startedCol": "Started",
|
||||||
|
"kindCol": "Kind",
|
||||||
|
"triggeredByCol": "Triggered by",
|
||||||
|
"okCol": "OK",
|
||||||
|
"skipCol": "Skipped",
|
||||||
|
"failCol": "Failed",
|
||||||
|
"triggeredByCron": "cron",
|
||||||
|
"kind": {
|
||||||
|
"monthly_issue": "Issuance",
|
||||||
|
"reminders": "Reminders"
|
||||||
|
},
|
||||||
|
"failureBannerTitle": "Recent automation failures detected",
|
||||||
|
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,8 @@
|
|||||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||||
"openclawTool": "Versions OpenClaw",
|
"openclawTool": "Versions OpenClaw",
|
||||||
"billingTool": "Facturation →",
|
"billingTool": "Facturation →",
|
||||||
"skillsQueueTool": "File d'activation"
|
"skillsQueueTool": "File d'activation",
|
||||||
|
"cronTool": "Automatisation"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utilisateurs autorisés",
|
"title": "Utilisateurs autorisés",
|
||||||
@@ -481,25 +482,29 @@
|
|||||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
|
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
|
||||||
},
|
},
|
||||||
"settingsBilling": {
|
"settingsBilling": {
|
||||||
"title": "Facturation",
|
"title": "Informations de facturation",
|
||||||
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
|
"subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
|
||||||
"companyName": "Nom de l'entreprise",
|
"companyNameLabel": "Nom de l'entreprise",
|
||||||
"streetAddress": "Adresse",
|
"streetAddressLabel": "Adresse",
|
||||||
"postalCode": "Code postal",
|
"postalCodeLabel": "Code postal",
|
||||||
"city": "Ville",
|
"cityLabel": "Ville",
|
||||||
"country": "Pays",
|
"countryLabel": "Code pays",
|
||||||
"vatNumber": "Numéro de TVA",
|
"countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
|
||||||
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
|
"vatNumberLabel": "Numéro de TVA (facultatif)",
|
||||||
"billingEmail": "E-mail de facturation",
|
"vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
|
||||||
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
|
"billingEmailLabel": "E-mail de facturation",
|
||||||
"notes": "Notes",
|
"billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
|
||||||
"notesPlaceholder": "Tout ce que la comptabilité doit savoir – exonération de TVA, modalités de facturation particulières, etc.",
|
"notesLabel": "Notes (facultatif)",
|
||||||
"save": "Enregistrer",
|
"notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
|
||||||
|
"saveChanges": "Enregistrer les modifications",
|
||||||
|
"createBilling": "Enregistrer les informations",
|
||||||
|
"saving": "Enregistrement…",
|
||||||
"saved": "Enregistré.",
|
"saved": "Enregistré.",
|
||||||
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
|
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||||
"lastUpdated": "Dernière mise à jour {when}",
|
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
|
||||||
"fullName": "Nom complet",
|
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
|
||||||
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
"fullNameLabel": "Nom et prénom",
|
||||||
|
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Support",
|
"title": "Support",
|
||||||
@@ -741,6 +746,40 @@
|
|||||||
"payWithCard": "Payer par carte",
|
"payWithCard": "Payer par carte",
|
||||||
"redirectingToStripe": "Redirection…",
|
"redirectingToStripe": "Redirection…",
|
||||||
"paymentReceived": "Paiement reçu — merci !",
|
"paymentReceived": "Paiement reçu — merci !",
|
||||||
"paymentCancelled": "Paiement annulé."
|
"paymentCancelled": "Paiement annulé.",
|
||||||
|
"configureBillingCta": "Configurer les informations de facturation",
|
||||||
|
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape."
|
||||||
|
},
|
||||||
|
"adminCron": {
|
||||||
|
"title": "Automatisation de la facturation",
|
||||||
|
"subtitle": "Émission mensuelle et balayage quotidien des rappels. Les deux s'exécutent automatiquement ; utilisez les boutons ci-dessous pour déclencher un lancement à la demande.",
|
||||||
|
"monthlyIssue": "Émission mensuelle",
|
||||||
|
"reminders": "Rappels",
|
||||||
|
"scheduleIssueLabel": "Planning",
|
||||||
|
"scheduleIssueValue": "00:30 Europe/Zurich le 1er",
|
||||||
|
"scheduleReminderLabel": "Planning",
|
||||||
|
"scheduleReminderValue": "09:00 Europe/Zurich quotidien",
|
||||||
|
"lastSuccess": "Dernière réussite",
|
||||||
|
"never": "jamais",
|
||||||
|
"runIssueNow": "Facturer le mois dernier maintenant",
|
||||||
|
"runRemindersNow": "Lancer les rappels maintenant",
|
||||||
|
"running": "En cours…",
|
||||||
|
"flashIssueOk": "Émission terminée : {success} factures émises, {skipped} ignorées, {failure} échouées.",
|
||||||
|
"flashRemindersOk": "Rappels envoyés : {success} réussis, {skipped} ignorés, {failure} échoués.",
|
||||||
|
"recentRuns": "Lancements récents (30 derniers)",
|
||||||
|
"noRunsYet": "Aucun lancement automatique enregistré pour le moment.",
|
||||||
|
"startedCol": "Démarré",
|
||||||
|
"kindCol": "Type",
|
||||||
|
"triggeredByCol": "Déclenché par",
|
||||||
|
"okCol": "OK",
|
||||||
|
"skipCol": "Ignorés",
|
||||||
|
"failCol": "Échoués",
|
||||||
|
"triggeredByCron": "cron",
|
||||||
|
"kind": {
|
||||||
|
"monthly_issue": "Émission",
|
||||||
|
"reminders": "Rappels"
|
||||||
|
},
|
||||||
|
"failureBannerTitle": "Échecs récents détectés",
|
||||||
|
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,7 +393,8 @@
|
|||||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||||
"openclawTool": "Versioni OpenClaw",
|
"openclawTool": "Versioni OpenClaw",
|
||||||
"billingTool": "Fatturazione →",
|
"billingTool": "Fatturazione →",
|
||||||
"skillsQueueTool": "Coda di attivazione"
|
"skillsQueueTool": "Coda di attivazione",
|
||||||
|
"cronTool": "Automazione"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utenti autorizzati",
|
"title": "Utenti autorizzati",
|
||||||
@@ -481,25 +482,29 @@
|
|||||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
|
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
|
||||||
},
|
},
|
||||||
"settingsBilling": {
|
"settingsBilling": {
|
||||||
"title": "Fatturazione",
|
"title": "Dati di fatturazione",
|
||||||
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.",
|
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
|
||||||
"companyName": "Ragione sociale",
|
"companyNameLabel": "Nome azienda",
|
||||||
"streetAddress": "Indirizzo",
|
"streetAddressLabel": "Indirizzo",
|
||||||
"postalCode": "CAP",
|
"postalCodeLabel": "CAP",
|
||||||
"city": "Città",
|
"cityLabel": "Città",
|
||||||
"country": "Paese",
|
"countryLabel": "Codice paese",
|
||||||
"vatNumber": "Partita IVA",
|
"countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
|
||||||
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).",
|
"vatNumberLabel": "Partita IVA (facoltativa)",
|
||||||
"billingEmail": "E-mail di fatturazione",
|
"vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
|
||||||
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.",
|
"billingEmailLabel": "E-mail di fatturazione",
|
||||||
"notes": "Note",
|
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
|
||||||
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.",
|
"notesLabel": "Note (facoltative)",
|
||||||
"save": "Salva",
|
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
|
||||||
|
"saveChanges": "Salva modifiche",
|
||||||
|
"createBilling": "Salva dati di fatturazione",
|
||||||
|
"saving": "Salvataggio…",
|
||||||
"saved": "Salvato.",
|
"saved": "Salvato.",
|
||||||
"saveFailed": "Impossibile salvare. Riprova.",
|
"missingRequired": "Compila tutti i campi obbligatori.",
|
||||||
"lastUpdated": "Ultimo aggiornamento {when}",
|
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||||
"fullName": "Nome completo",
|
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||||
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
"fullNameLabel": "Nome e cognome",
|
||||||
|
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture."
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"title": "Supporto",
|
"title": "Supporto",
|
||||||
@@ -741,6 +746,40 @@
|
|||||||
"payWithCard": "Paga con carta",
|
"payWithCard": "Paga con carta",
|
||||||
"redirectingToStripe": "Reindirizzamento…",
|
"redirectingToStripe": "Reindirizzamento…",
|
||||||
"paymentReceived": "Pagamento ricevuto — grazie!",
|
"paymentReceived": "Pagamento ricevuto — grazie!",
|
||||||
"paymentCancelled": "Pagamento annullato."
|
"paymentCancelled": "Pagamento annullato.",
|
||||||
|
"configureBillingCta": "Configura dati di fatturazione",
|
||||||
|
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio."
|
||||||
|
},
|
||||||
|
"adminCron": {
|
||||||
|
"title": "Automazione fatturazione",
|
||||||
|
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.",
|
||||||
|
"monthlyIssue": "Emissione mensile",
|
||||||
|
"reminders": "Solleciti",
|
||||||
|
"scheduleIssueLabel": "Pianificazione",
|
||||||
|
"scheduleIssueValue": "00:30 Europe/Zurich il 1°",
|
||||||
|
"scheduleReminderLabel": "Pianificazione",
|
||||||
|
"scheduleReminderValue": "09:00 Europe/Zurich quotidianamente",
|
||||||
|
"lastSuccess": "Ultimo successo",
|
||||||
|
"never": "mai",
|
||||||
|
"runIssueNow": "Fattura il mese scorso ora",
|
||||||
|
"runRemindersNow": "Avvia solleciti ora",
|
||||||
|
"running": "In corso…",
|
||||||
|
"flashIssueOk": "Emissione completata: {success} fatture emesse, {skipped} ignorate, {failure} fallite.",
|
||||||
|
"flashRemindersOk": "Solleciti inviati: {success} riusciti, {skipped} ignorati, {failure} falliti.",
|
||||||
|
"recentRuns": "Esecuzioni recenti (ultime 30)",
|
||||||
|
"noRunsYet": "Nessuna esecuzione automatica registrata.",
|
||||||
|
"startedCol": "Avviata",
|
||||||
|
"kindCol": "Tipo",
|
||||||
|
"triggeredByCol": "Avviata da",
|
||||||
|
"okCol": "OK",
|
||||||
|
"skipCol": "Ignorati",
|
||||||
|
"failCol": "Falliti",
|
||||||
|
"triggeredByCron": "cron",
|
||||||
|
"kind": {
|
||||||
|
"monthly_issue": "Emissione",
|
||||||
|
"reminders": "Solleciti"
|
||||||
|
},
|
||||||
|
"failureBannerTitle": "Fallimenti recenti rilevati",
|
||||||
|
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,6 +542,20 @@ export type InvoiceStatus =
|
|||||||
|
|
||||||
export type InvoicePaymentMethod = "invoice" | "card";
|
export type InvoicePaymentMethod = "invoice" | "card";
|
||||||
|
|
||||||
|
// Phase 5 — Cron run history rows for the admin /admin/cron page.
|
||||||
|
export type CronRunKind = "monthly_issue" | "reminders";
|
||||||
|
export interface CronRun {
|
||||||
|
id: string;
|
||||||
|
runKind: CronRunKind;
|
||||||
|
triggeredBy: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorDetails: unknown | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type InvoiceLineKind =
|
export type InvoiceLineKind =
|
||||||
| "tenant_monthly"
|
| "tenant_monthly"
|
||||||
| "tenant_setup"
|
| "tenant_setup"
|
||||||
|
|||||||
Reference in New Issue
Block a user