Compare commits

...

11 Commits

Author SHA1 Message Date
875ade4351 Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-24 23:59:05 +02:00
2a0bb10531 Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 56s
2026-05-24 23:54:49 +02:00
262250564a Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 23:48:39 +02:00
a680d6de9f Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-24 23:37:48 +02:00
4a5ae0bb8b Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m37s
2026-05-24 22:21:26 +02:00
c21b48c704 Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-24 21:47:37 +02:00
cf190e5ac5 Phase3: Billing Customerpage/Mailings
Some checks failed
Build and Push / build (push) Failing after 46s
2026-05-24 21:44:10 +02:00
a3b080f542 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m41s
2026-05-24 18:35:36 +02:00
229bfea263 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
2026-05-24 17:51:09 +02:00
49b085e59e Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
2026-05-24 17:25:08 +02:00
cd15b391ac Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s
2026-05-24 16:38:41 +02:00
44 changed files with 4182 additions and 114 deletions

18
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -7530,6 +7531,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "22.1.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -21,6 +21,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {

View File

@@ -2,6 +2,7 @@ import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
import { countPendingSkillActivationRequests } from "@/lib/db";
import { AdminPanel } from "@/components/admin/admin-panel";
export default async function AdminPage() {
@@ -19,6 +20,12 @@ export default async function AdminPage() {
}
const tenants = await listTenants();
// Phase 2.5: badge counter for the skill-activation admin queue.
// Cheap COUNT(*) on a partial-indexed status='pending' column —
// bounded by request volume and never expected to be high.
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
() => 0
);
return (
<div>
@@ -33,6 +40,21 @@ export default async function AdminPage() {
than nav-shell entries — these are platform-team utilities,
not main navigation. */}
<div className="flex items-center gap-2">
<a
href="/admin/skills/pending"
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
pendingSkillCount > 0
? "border-warning text-warning hover:bg-warning/10"
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
}`}
>
<span>{t("skillsQueueTool")}</span>
{pendingSkillCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
{pendingSkillCount}
</span>
)}
</a>
<a
href="/admin/billing"
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"

View File

@@ -0,0 +1,59 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { BackLink } from "@/components/ui/back-link";
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
/**
* /admin/skills/pending — admin queue for manual-setup skill
* activation requests. Each row shows tenant, skill, requester
* info, and offers Approve / Reject actions.
*
* Server-renders the initial list. Approval/rejection trigger a
* client-side fetch + router.refresh() so the row disappears and
* the count updates without a hard reload.
*/
export default async function AdminPendingSkillRequestsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminSkills");
const pending = await listPendingSkillActivationRequests();
// Hydrate display fields: skill name from catalog, org company name
// from billing. Skill name fallback to skillId for off-catalog
// entries (shouldn't happen but defensive). Company name is
// looked up lazily per row; dedup'd via a Map so we don't issue
// duplicate getOrgBilling calls for the same org.
const seenOrg = new Map<string, string | null>();
const rows = await Promise.all(
pending.map(async (r) => {
if (!seenOrg.has(r.zitadelOrgId)) {
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
}
const def = getPackageDef(r.skillId);
return {
...r,
skillName: def?.name ?? r.skillId,
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
};
})
);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin" label={t("backToAdmin")} />
<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>
<PendingSkillRequests initialRows={rows} />
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { redirect, notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
/**
* /billing/[invoiceNumber] — single-invoice view.
*
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
* format printed on the PDF and in the issuance email). Org
* filter is enforced in the DB query — a customer trying another
* org's number gets 404, not 403, to avoid leaking the existence
* of other orgs' invoices.
*/
export default async function CustomerInvoiceDetailPage({
params,
}: {
params: Promise<{ invoiceNumber: string; locale: string }>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
const { invoiceNumber } = await params;
const t = await getTranslations("customerBilling");
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) notFound();
return (
<main className="max-w-3xl mx-auto px-6 py-8">
<BackLink href="/billing" label={t("backToBilling")} />
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
</main>
);
}

View File

@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
/**
* /billing — customer's billing home.
*
* Shows two things:
* 1. RunningTotalWidget — current calendar month's accruing cost
* (or the already-issued invoice for the current month, if
* that ran early).
* 2. CustomerInvoiceList — every issued invoice for this org,
* newest first. Status is reflected with a colored badge.
*
* Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view. Phase 4 will add a
* "settings.payByInvoice" toggle visibility-gated to owners only.
*/
export default async function CustomerBillingPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("customerBilling");
// Sync overdue status before listing — cheap, idempotent.
try {
await syncOverdueInvoices();
} catch (e) {
console.warn("syncOverdueInvoices failed in /billing:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
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>
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("currentPeriodHeading")}
</h2>
<RunningTotalWidget />
</section>
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("historyHeading")}
</h2>
<CustomerInvoiceList invoices={invoices} />
</section>
</main>
);
}

View File

@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { getPendingResumeRequestForTenant } from "@/lib/db";
import {
getPendingResumeRequestForTenant,
listSkillActivationRequestsForTenant,
listSkillPricing,
} from "@/lib/db";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
@@ -82,6 +86,17 @@ export default async function TenantDetailPage({
);
const channelUsers = tenant.spec.channelUsers || {};
// Phase 2.5: surface pending and most-recently-rejected skill
// activation requests so PackageCard can render the inline
// "Manual review pending" / "Activation rejected" states.
// Pricing drives the cost-disclosure dialog before enable.
// Both fetches are best-effort — an empty list is the safe
// fallback if the DB call fails (cards just show normal toggles).
const [activationRequests, skillPricing] = await Promise.all([
listSkillActivationRequestsForTenant(name).catch(() => []),
listSkillPricing().catch(() => []),
]);
// Bug 19 fix: every viewer (customer or admin) passes the tenant
// name to UsageDisplay. The /api/usage route resolves team+alias
// from the tenant CR's status and applies the visibility check, so
@@ -219,6 +234,8 @@ export default async function TenantDetailPage({
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
activationRequests={activationRequests}
skillPricing={skillPricing}
/>
</section>

View File

@@ -24,6 +24,9 @@ import { safeError } from "@/lib/errors";
const upsertSchema = z.object({
skillId: z.string().min(1).max(100),
dailyPriceChf: z.number().min(0).max(1_000_000),
// Optional with default 0 so existing API callers keep working.
// Setup fee fires once per (tenant, skill); see billing.ts.
setupFeeChf: z.number().min(0).max(1_000_000).optional().default(0),
});
export async function GET() {
@@ -63,7 +66,8 @@ export async function PUT(request: Request) {
try {
const row = await setSkillPricing(
parsed.data.skillId,
parsed.data.dailyPriceChf
parsed.data.dailyPriceChf,
parsed.data.setupFeeChf
);
return NextResponse.json(row);
} catch (e) {

View File

@@ -0,0 +1,155 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
recordSkillEvents,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationApprovalEmail } from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/skills/pending/[id]/approve
*
* Atomic-ish approval. Ordering:
* 1. Load + sanity-check the request (must be pending).
* 2. Patch the tenant CR to include the skill in spec.packages.
* 3. Record the skill_event (kind=enabled) for billing.
* 4. Flip the request row to 'approved'.
* 5. Best-effort approval email to the requester.
*
* Step 2 is the irreversible one — if it succeeds but step 4 fails
* we end up with a skill enabled in K8s but a still-pending request
* row. That's a manual cleanup task; we log loudly so admin notices
* via the queue page (the request would reappear there).
*
* The request must be in 'pending' status. Approving an already-
* approved/rejected request returns 409.
*
* Body (optional): { adminNotes?: string }
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const adminNotes =
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
? body.adminNotes
: null;
// 1. Load + sanity-check.
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
// 2. Patch the tenant CR — add the skill if not already present.
// Defensive: if the tenant was deleted or the skill was somehow
// added by another path, we still proceed without duplicate.
let tenant;
try {
tenant = await getTenant(req.tenantName);
} catch (e) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
{ status: 404 }
);
}
if (!tenant) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found` },
{ status: 404 }
);
}
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
const alreadyEnabled = currentPackages.has(req.skillId);
if (!alreadyEnabled) {
currentPackages.add(req.skillId);
try {
await patchTenantSpec(req.tenantName, {
packages: [...currentPackages],
});
} catch (e) {
return NextResponse.json(
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
{ status: 500 }
);
}
}
// 3. Record skill event (only if we actually added it — re-adding
// would skew the day-count). Best-effort.
if (!alreadyEnabled) {
try {
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
} catch (e) {
console.error(
`Failed to record skill_event after approve (request ${id}):`,
e
);
}
}
// 4. Flip request to approved.
const updated = await updateSkillActivationRequestStatus(id, "approved", {
reviewedBy: admin.id,
adminNotes,
});
if (!updated) {
// Race: another admin tab flipped it between our read and now.
// The K8s patch already happened so we don't roll back; log so
// the human notices.
console.error(
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
);
return NextResponse.json(
{
error:
"Request status changed during approval; the skill may have been enabled. Check the queue.",
},
{ status: 409 }
);
}
// 5. Email the requester (best-effort). Look up their email via
// ZITADEL since we only stored the userId on the request.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationApprovalEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
});
}
} catch (e) {
console.error(`Failed to send approval email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationRejectionEmail } from "@/lib/email";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/admin/skills/pending/[id]/reject
*
* Reject a pending activation request with a required reason that
* is shown to the customer (mirroring the tenant-request rejection
* flow). The skill is NOT added to the tenant spec — it was never
* there in the first place — so the customer's enable attempt is
* effectively cancelled. They can try again from their tenant
* settings after seeing the reason (a new pending row will be
* created by their next toggle).
*
* Body:
* {
* reason: string (1..1000 chars, required),
* adminNotes?: string (optional, not shown to customer)
* }
*/
const bodySchema = z.object({
reason: z.string().min(1).max(1000),
adminNotes: z.string().max(1000).optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
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 }
);
}
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
reviewedBy: admin.id,
rejectionReason: parsed.data.reason,
adminNotes: parsed.data.adminNotes ?? null,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during rejection." },
{ status: 409 }
);
}
// Cleanup: if the package needed customer-provided secrets, the
// user submitted them BEFORE the gate fired (handleSubmitSecrets
// in PackageCard writes to OpenBao then PATCHes). Those secrets
// are now orphaned — the package never made it into spec, won't
// be re-attempted unless the user retries with fresh credentials.
// Best-effort delete: keep the OpenBao path clean, avoid stale
// creds lurking. Idempotent (404 is fine). Failure is logged but
// not propagated — the rejection itself already succeeded.
//
// We deliberately skip customProvisioning packages here. Those
// mint platform-side credentials via a dedicated endpoint and
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
// — admin handles that path manually if the rejected request had
// already minted resources.
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
e
);
}
}
// Email the requester with the reason — best-effort.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationRejectionEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
reason: parsed.data.reason,
});
}
} catch (e) {
console.error(`Failed to send rejection email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listPendingSkillActivationRequests } from "@/lib/db";
/**
* GET /api/admin/skills/pending
*
* List all pending skill-activation requests across all tenants
* and orgs. Powers the admin queue at /admin/skills/pending.
*
* Platform-role only. Returns up to 500 rows oldest-first so the
* queue UI shows the oldest requests at the top (FIFO).
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await listPendingSkillActivationRequests();
return NextResponse.json(rows);
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { computeInvoiceDraft } from "@/lib/billing";
import { listInvoices } from "@/lib/db";
/**
* GET /api/billing/current
*
* Running total for the current calendar month — what the
* customer will be billed if no further activity happens. Uses
* the same compute pipeline as the final invoice (LiteLLM spend,
* Threema usage, skill day-counting, proration) so the number
* the customer sees matches what they'll eventually receive
* within the limits of intra-month drift.
*
* If an invoice has ALREADY been issued for the current month
* (e.g. cron ran early, admin manually generated), we return
* that issued invoice instead — no point showing a draft that
* duplicates a real invoice.
*
* Returns:
* { issued: Invoice } // current-month invoice exists
* { draft: InvoiceDraft } // still accruing
* { error: ... } // org missing billing config
*
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
* DB queries per skill. Sub-second typically. No caching; called
* on demand from the customer billing page.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Resolve current calendar month from UTC. Billing is UTC-day
// based throughout (see billing.ts iterDays comment), so the
// running total inherits that same semantics.
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1; // 1-12
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
// 1. Has the current month already been invoiced?
const existing = await listInvoices({
zitadelOrgId: user.orgId,
periodMonth,
limit: 1,
});
if (existing.length > 0) {
return NextResponse.json({ issued: existing[0] });
}
// 2. Otherwise compute the draft. Falls through to error if the
// org doesn't have a billing config yet (no Address on file).
try {
const draft = await computeInvoiceDraft({
zitadelOrgId: user.orgId,
year,
month,
});
return NextResponse.json({ draft });
} catch (e: any) {
// Most likely: org_billing row missing. We surface a 200 with a
// soft error code rather than 500 — the customer-side widget
// displays a helpful "complete your billing details" message
// instead of a stack trace.
return NextResponse.json(
{
error: e?.message ?? "Could not compute running total.",
code: e?.code ?? "COMPUTE_FAILED",
},
{ status: 200 }
);
}
}

View File

@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getInvoiceByNumberForOrg,
getOrgBilling,
} from "@/lib/db";
import {
createCheckoutSessionForInvoice,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/invoices/[invoiceNumber]/pay
*
* Initiates a Stripe Checkout Session for an open invoice. Returns
* `{ url }` — the browser is expected to navigate to that URL,
* where Stripe hosts the payment UI.
*
* Authorization: caller must belong to the invoice's org (the DB
* query enforces this — wrong-org returns 404, indistinguishable
* from a non-existent invoice).
*
* Preconditions enforced server-side:
* - Invoice exists for caller's org
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
* all reject — already-paid invoices in particular must not
* create a second Checkout Session, even though Stripe would
* deduplicate the actual charge)
*
* The Stripe Customer for the org is lazily ensured here — first
* card click on an org creates the customer; subsequent clicks
* reuse the persisted stripe_customer_id.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const inv = detail.invoice;
if (inv.status !== "open" && inv.status !== "overdue") {
return NextResponse.json(
{
error:
inv.status === "paid"
? "This invoice has already been paid."
: `This invoice cannot be paid online (status: ${inv.status}).`,
},
{ status: 409 }
);
}
// We need org_billing for the customer creation address. The
// invoice has a SNAPSHOT but that's frozen at issue time; for
// creating/updating the Stripe customer we want the current
// address (which may have been corrected since the invoice).
// Snapshot is still authoritative on the invoice PDF and total.
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing details are not configured for your organization." },
{ status: 400 }
);
}
try {
const customerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: orgBilling.companyName,
billingEmail: orgBilling.billingEmail,
address: {
line1: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
},
});
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const { url } = await createCheckoutSessionForInvoice({
invoice: inv,
customerId,
baseUrl,
});
return NextResponse.json({ url });
} catch (e) {
console.error(
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
e
);
return NextResponse.json(
{ error: safeError(e, "Failed to start card payment.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]/pdf
*
* Customer-facing PDF download. Same Uint8Array.from() variance
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
* for the rationale.
*
* Authorization: looks up the invoice by number with org scope
* baked into the query, then re-fetches the PDF blob by id. A
* customer can't probe another org's invoice numbers — they get
* 404 either way.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return new NextResponse("Not found", { status: 404 });
}
const pdf = await getInvoicePdf(detail.invoice.id);
if (!pdf) {
return new NextResponse("PDF not available", { status: 404 });
}
const body = Uint8Array.from(pdf.data);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]
*
* Customer-scoped detail lookup by invoice number (the human-
* readable YYYY-NNNNN format the customer sees on the PDF). The
* org filter is part of the DB query — a customer probing another
* org's invoice number gets the same 404 as a non-existent one.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(detail);
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
/**
* GET /api/billing/invoices
*
* Customer-scoped list of invoices for the caller's org. Returns
* a flat array of Invoice headers (no line items — those are
* fetched separately by /[invoiceNumber]).
*
* Status filter is implicit: we return every invoice the
* customer's org has, all statuses (issued/paid/overdue/void)
* because the customer wants a single billing-history view.
*
* Before returning we run syncOverdueInvoices() so the displayed
* status reflects the current date — issued invoices past their
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
* a separate cron for this transition.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Personal accounts have an org too — they share the same shape;
// their invoices show up under that synthetic org id.
try {
await syncOverdueInvoices();
} catch (e) {
// Non-fatal — display stale status rather than 500.
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
return NextResponse.json(invoices);
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillPricing } from "@/lib/db";
/**
* GET /api/skills/pricing
*
* Returns the platform-wide skill pricing (daily price + setup fee
* per skill) for display in the customer's cost-disclosure dialog
* before they enable a priced skill. Any logged-in user can read
* this — pricing isn't org-specific and is effectively public
* information for anyone who'd be considering activation.
*
* Empty array means no skill is currently priced.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const rows = await listSkillPricing();
return NextResponse.json(rows);
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/skills/requests/[id]/withdraw
*
* The owner of a pending activation request can cancel it. This
* doesn't touch K8s (the skill was never enabled) — it just flips
* the row to 'withdrawn' so the user's UI clears the pending
* state and they can try a different skill or retry later.
*
* Authorization: only the original requester OR a platform admin
* can withdraw a request. We deliberately don't allow other org
* members to cancel each other's requests in v1 — the partial
* unique index would let one user repeatedly cancel another's
* pending request.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (!user.isPlatform && req.zitadelUserId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
reviewedBy: user.id,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during withdraw." },
{ status: 409 }
);
}
// Cleanup: same logic as reject — the user submitted secrets
// before the gate fired, and those are now orphaned in OpenBao.
// Best-effort delete; failure logged but not propagated. Skip
// customProvisioning packages (their deprovisioning is a
// separate, dedicated endpoint).
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
e
);
}
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillActivationRequestsForTenant } from "@/lib/db";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant } from "@/lib/k8s";
/**
* GET /api/skills/requests?tenant=<name>
*
* Returns pending and most-recent-rejected skill activation
* requests for the named tenant. Used by the tenant settings page
* to render the "Manual review pending" or "Activation rejected"
* inline states on PackageCard.
*
* Authorization: the caller must be able to see the tenant (owner
* of its org, assigned user, or platform admin).
*/
export async function GET(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const tenantName = searchParams.get("tenant");
if (!tenantName) {
return NextResponse.json(
{ error: "Missing tenant parameter" },
{ status: 400 }
);
}
const tenant = await getTenant(tenantName).catch(() => null);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!canUserSeeTenant(user, tenant)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const requests = await listSkillActivationRequestsForTenant(tenantName);
return NextResponse.json(requests);
}

View File

@@ -0,0 +1,232 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
import {
markInvoicePaid,
markStripeEventProcessed,
setInvoiceStripePaymentIntent,
tryRecordStripeEvent,
} from "@/lib/db";
/**
* POST /api/stripe/webhook
*
* Receives signed events from Stripe. The lifecycle:
*
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
* HMAC is computed over the raw bytes and any JSON re-parse
* will subtly mangle whitespace or property ordering and the
* signature will fail).
* 2. Verify signature against the configured webhook secret. If
* verification fails → 400. An attacker forging webhook calls
* could otherwise mark our invoices paid.
* 3. Idempotency: INSERT the event id into stripe_events. If the
* INSERT conflicts (duplicate delivery, which is normal — Stripe
* retries failed deliveries for up to 72h), return 200 immediately
* so Stripe doesn't keep retrying.
* 4. Process the event based on type. Currently we care about:
* - checkout.session.completed → flip invoice to paid
* - charge.refunded → log; void/credit handling is Phase 7
* - payment_intent.payment_failed → log only; the failure is
* already shown to the user on
* the Stripe page, no action.
* Unknown event types are ack'd with 200 (we may have other
* events enabled at the Stripe end that we don't yet care about,
* and 200 + log is cheaper than 404 + Stripe retries).
* 5. Stamp processed_at on success.
*
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
* Stripe retries with exponential backoff up to 72h. We aim for
* 200 on every reachable path (verified, deduplicated, or processed),
* and only 400 for signature failures (those would never succeed
* on retry anyway, so retrying is wasted effort).
*
* Performance: handlers run synchronously here because PieCed's
* event volume is tiny. If/when that changes, the obvious refactor
* is to enqueue (Phase 7) and ack first — but at v1 the inline
* model is simpler to reason about and harder to lose events with.
*/
// Next.js: explicitly disable static optimization; this route MUST
// run on every request and must not be cached.
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
// 1. Raw body — Stripe verifies the signature over these exact bytes.
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new NextResponse("Missing stripe-signature header", {
status: 400,
});
}
// 2. Verify signature.
let event: Stripe.Event;
try {
const stripe = getStripeClient();
const secret = getWebhookSecret();
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (err) {
console.error("Stripe webhook signature verification failed:", err);
// 400 — never retry. The webhook configuration is wrong on
// either end (rotated secret, wrong endpoint, etc.); retries
// won't fix it.
return new NextResponse("Invalid signature", { status: 400 });
}
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
let firstDelivery: boolean;
try {
firstDelivery = await tryRecordStripeEvent(
event.id,
event.type,
event
);
} catch (err) {
console.error(
`Failed to record stripe event ${event.id} (${event.type}):`,
err
);
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
return new NextResponse("DB error", { status: 500 });
}
if (!firstDelivery) {
// Already processed; ack happily.
return new NextResponse("Duplicate delivery; acknowledged.", {
status: 200,
});
}
// 4. Process. Each handler is responsible for being safe to run
// exactly once (we already deduplicated by event.id above).
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "charge.refunded":
await handleChargeRefunded(event.data.object as Stripe.Charge);
break;
case "payment_intent.payment_failed":
await handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
default:
// Unknown event — log so we notice if Stripe starts sending
// something we should handle, but ack so we don't accumulate
// retries forever.
console.log(
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
);
}
} catch (err) {
console.error(
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
err
);
// 5xx → Stripe retries. The handler is idempotent because the
// stripe_events row already exists, so on the next attempt we'd
// short-circuit at step 3. To actually retry the work we'd need
// to DELETE the stripe_events row first; for v1 we don't bother
// and let a human investigate the logs.
return new NextResponse("Handler error", { status: 500 });
}
// 5. Mark processed.
try {
await markStripeEventProcessed(event.id);
} catch (err) {
// Non-fatal — the event was already processed, this is just the
// bookkeeping flag. Log and move on.
console.error(
`Failed to mark stripe event ${event.id} processed:`,
err
);
}
return new NextResponse("OK", { status: 200 });
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
// Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice
// when payment actually cleared.
if (session.payment_status !== "paid") {
console.log(
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
);
return;
}
const invoiceId =
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
if (!invoiceId) {
console.error(
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
);
return;
}
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id;
// Persist the PaymentIntent id on the invoice for traceability +
// future refund correlation.
if (paymentIntentId) {
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
}
// Flip status. markInvoicePaid is idempotent — re-running on an
// already-paid invoice returns null and we log + skip.
const updated = await markInvoicePaid(invoiceId, {
paidBy: "stripe",
paidMethodDetail: paymentIntentId
? `Stripe Checkout (${paymentIntentId})`
: "Stripe Checkout",
paidAt: session.created ? new Date(session.created * 1000) : undefined,
});
if (!updated) {
// Already paid or void/draft — fine, nothing to do.
console.log(
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
);
return;
}
console.log(
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// v1 scope: log only. Refunds always go through Stripe → admin
// initiates them in the dashboard. Updating our invoice status
// to 'void' or partial-credit needs more product thinking
// (partial refunds? credit notes? VAT corrections?). Phase 7.
console.log(
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
);
}
async function handlePaymentFailed(
intent: Stripe.PaymentIntent
): Promise<void> {
// The Stripe-hosted page already shows the failure to the user.
// We log here for support visibility and to surface in Workbench.
// No invoice state change — it stays 'open' until paid.
console.log(
`PaymentIntent ${intent.id} failed: ${
intent.last_payment_error?.message ?? "(no message)"
}`
);
}

View File

@@ -3,7 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { recordSkillEvents } from "@/lib/db";
import {
createSkillActivationRequest,
getOrgBilling,
recordSkillEvents,
} from "@/lib/db";
import { sendSkillActivationAdminNotification } from "@/lib/email";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
@@ -69,6 +74,17 @@ export async function PATCH(
const specPatch: Record<string, any> = {};
// Track manual-setup gate activations created during this PATCH.
// We push to the K8s spec only the non-gated skills; the gated
// ones live in skill_activation_requests until admin approves
// and adds them via the admin endpoint. Platform admins bypass
// the gate (direct enable from /admin still applies immediately).
let gatedRequests: Array<{
skillId: string;
requestId: string;
skillName: string;
}> = [];
// ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
@@ -85,7 +101,63 @@ export async function PATCH(
);
}
}
specPatch.packages = body.packages;
// Compute the to-be-added set against the existing spec.
const existingPackages = new Set<string>(existing.spec.packages ?? []);
const desiredPackages: string[] = body.packages;
const newlyAdded = desiredPackages.filter(
(p) => !existingPackages.has(p)
);
// Manual-setup gate. Customer adds get routed to the queue;
// platform admins go straight through.
if (!user.isPlatform && newlyAdded.length > 0) {
const orgIdForGate =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!orgIdForGate) {
// Defensive: every customer-visible tenant should have the
// org label. Without it we can't attribute the request.
return NextResponse.json(
{ error: "Tenant missing org binding; contact support." },
{ status: 500 }
);
}
const gatedSet = new Set<string>();
for (const skillId of newlyAdded) {
const def = getPackageDef(skillId);
if (!def?.requiresManualSetup) continue;
gatedSet.add(skillId);
try {
const req = await createSkillActivationRequest({
tenantName: name,
zitadelOrgId: orgIdForGate,
zitadelUserId: user.id,
skillId,
});
gatedRequests.push({
skillId,
requestId: req.id,
skillName: def.name,
});
} catch (e: any) {
if (e?.code === "REQUEST_ALREADY_PENDING") {
// Idempotent: a pending row already exists; just keep
// the skill out of the K8s spec and surface it as
// gated without creating a duplicate.
gatedRequests.push({
skillId,
requestId: "",
skillName: def.name,
});
} else {
throw e;
}
}
}
// Strip gated skills from the desired spec — they must not
// reach K8s until approved.
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
} else {
specPatch.packages = desiredPackages;
}
}
// ── Validate workspaceFiles ──
@@ -232,7 +304,49 @@ export async function PATCH(
}
}
return NextResponse.json(updated);
// Phase 2.5: notify admin of newly created activation requests.
// Best-effort — email failure must not poison the PATCH response.
// requestId === "" means an existing-pending row was reused, so
// skip the email in that case (admin already knows).
if (gatedRequests.length > 0) {
const orgIdForEmail =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
const companyName = orgIdForEmail
? await getOrgBilling(orgIdForEmail)
.then((b) => b?.companyName ?? null)
.catch(() => null)
: null;
for (const g of gatedRequests) {
if (!g.requestId) continue;
try {
await sendSkillActivationAdminNotification({
tenantName: name,
skillId: g.skillId,
skillName: g.skillName,
requesterEmail: user.email,
requesterName: user.name,
companyName,
});
} catch (e) {
console.error(
`Failed to send admin notification for skill activation request:`,
e
);
}
}
}
return NextResponse.json({
...updated,
// Phase 2.5: tells the client which requested-to-enable skills
// didn't actually land in the spec because they're awaiting
// admin approval. UI uses this to render the "pending review"
// state on those skill cards.
pendingActivationRequests: gatedRequests.map((g) => ({
skillId: g.skillId,
skillName: g.skillName,
})),
});
} catch (e: any) {
return NextResponse.json(
{ error: safeError(e, "Failed to update tenant") },

View File

@@ -34,6 +34,7 @@ export function PricingEditor({
catalog,
}: Props) {
const t = useTranslations("adminBilling");
const tPackages = useTranslations("packages");
const router = useRouter();
// -- Platform pricing form ----------------------------------------------
@@ -78,27 +79,41 @@ export function PricingEditor({
}
};
// -- Skill pricing ------------------------------------------------------
// -- Package pricing ----------------------------------------------------
// Server is authoritative — we don't keep an editable local copy of the
// table; instead each action posts to the API and we router.refresh().
const [newSkillId, setNewSkillId] = useState(
catalog.find((c) => c.category === "skill")?.id ?? ""
);
//
// Naming carry-over: the underlying DB table is `skill_pricing` and the
// column is `skill_id`, dating from when only skills were priced. The
// model now applies to any PackageDef in the catalog regardless of
// category — core, channel, or skill. The state variable names below
// (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix
// because renaming the entire surface for purely cosmetic reasons
// would create churn for no functional gain. Treat "skill" here as
// shorthand for "priced package".
const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? "");
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState("");
// Core upsert — used by both the "add new skill" form and the inline
// editor on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event.
const upsertSkillPrice = async (skillId: string, dailyPriceChf: number) => {
// editors on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event. Both `dailyPriceChf` and
// `setupFeeChf` are written together because the API does a full
// upsert; partial updates would silently zero the other field.
const upsertSkillPrice = async (
skillId: string,
dailyPriceChf: number,
setupFeeChf: number
) => {
setAddingSkill(true);
setSkillError("");
try {
const res = await fetch("/api/admin/billing/skill-pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId, dailyPriceChf }),
body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
@@ -115,7 +130,11 @@ export function PricingEditor({
const onAddNewSkill = (e: React.FormEvent) => {
e.preventDefault();
if (!newSkillId) return;
void upsertSkillPrice(newSkillId, Number(newSkillPrice));
void upsertSkillPrice(
newSkillId,
Number(newSkillPrice),
Number(newSkillSetupFee)
);
};
const deleteSkill = async (skillId: string) => {
@@ -136,9 +155,16 @@ export function PricingEditor({
}
};
// Catalog filtered to skill-kind entries for the picker, but keeping
// existing pricing rows even if they reference non-skill packages.
const skillCatalogOptions = catalog.filter((c) => c.category === "skill");
// Pricing applies to any catalog entry regardless of category. Grouped
// dropdown sorts options by category for visual scanning — core,
// channel, and skill in a single picker.
const skillCatalogOptions = [...catalog].sort((a, b) => {
const order = { core: 0, channel: 1, skill: 2 } as Record<string, number>;
const ca = order[a.category] ?? 99;
const cb = order[b.category] ?? 99;
if (ca !== cb) return ca - cb;
return a.name.localeCompare(b.name);
});
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
@@ -234,6 +260,7 @@ export function PricingEditor({
<tr>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
@@ -248,14 +275,35 @@ export function PricingEditor({
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted">{entry.name}</div>
<div className="text-xs text-text-muted flex items-center gap-2">
<span>{entry.name}</span>
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
{entry.category}
</span>
</div>
)}
</td>
<td className="py-2 text-right">
{/* Inline edits write daily + setup together (full
upsert on the API side). The other field is
held constant from the snapshot here. */}
<InlinePriceEditor
skillId={sp.skillId}
initialPrice={sp.dailyPriceChf}
onSave={(price) => upsertSkillPrice(sp.skillId, price)}
decimals={4}
onSave={(price) =>
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
}
/>
</td>
<td className="py-2 text-right">
<InlinePriceEditor
skillId={`${sp.skillId}-setup`}
initialPrice={sp.setupFeeChf}
decimals={2}
onSave={(fee) =>
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
}
/>
</td>
<td className="py-2 text-right">
@@ -283,18 +331,50 @@ export function PricingEditor({
onChange={(e) => setNewSkillId(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{skillCatalogOptions
.filter((c) => !pricedIds.has(c.id))
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
{(() => {
// Group available options by category for the picker.
// Already-priced packages are filtered out (admin
// edits those inline above).
const available = skillCatalogOptions.filter(
(c) => !pricedIds.has(c.id)
);
const byCat = new Map<string, typeof available>();
for (const c of available) {
if (!byCat.has(c.category)) byCat.set(c.category, []);
byCat.get(c.category)!.push(c);
}
// Labels for the optgroups. Reuse the existing
// packages.categories.* scope which already has
// translations in all four locales.
const labels: Record<string, string> = {
core: tPackages("categories.core"),
channel: tPackages("categories.channels"),
skill: tPackages("categories.skills"),
};
const order: Array<"core" | "channel" | "skill"> = [
"core",
"channel",
"skill",
];
return order.map((cat) => {
const items = byCat.get(cat);
if (!items || items.length === 0) return null;
return (
<optgroup key={cat} label={labels[cat] ?? cat}>
{items.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
</optgroup>
);
});
})()}
</select>
</label>
<label className="w-32">
<label className="w-28">
<span className="text-xs text-text-muted">
{t("dailyPriceLabel")} (CHF)
{t("dailyPriceLabel")}
</span>
<input
type="number"
@@ -305,6 +385,19 @@ export function PricingEditor({
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("skillSetupFeeLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillSetupFee}
onChange={(e) => setNewSkillSetupFee(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<button
type="submit"
disabled={addingSkill || !newSkillId}
@@ -322,23 +415,30 @@ export function PricingEditor({
}
/**
* Tiny inline editor for a single skill's daily price. Mounts in
* Tiny inline editor for a single numeric price/fee. Mounts in
* "view" mode showing the current value as a clickable badge;
* clicking turns it into an input + save/cancel buttons.
*
* `decimals` controls the display precision in view mode AND the
* step granularity of the input (daily prices use 4dp, setup fees
* use 2dp).
*/
function InlinePriceEditor({
skillId,
initialPrice,
decimals = 2,
onSave,
}: {
skillId: string;
initialPrice: number;
decimals?: number;
onSave: (price: number) => Promise<void> | void;
}) {
const t = useTranslations("adminBilling");
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(String(initialPrice));
const [busy, setBusy] = useState(false);
const step = decimals === 4 ? "0.0001" : "0.01";
if (!editing) {
return (
@@ -347,7 +447,7 @@ function InlinePriceEditor({
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
CHF {initialPrice.toFixed(2)}
CHF {initialPrice.toFixed(decimals)}
</button>
);
}
@@ -355,7 +455,7 @@ function InlinePriceEditor({
<span className="inline-flex items-center gap-1">
<input
type="number"
step="0.01"
step={step}
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}

View File

@@ -0,0 +1,204 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { SkillActivationRequest } from "@/types";
interface RowData extends SkillActivationRequest {
skillName: string;
companyName: string | null;
}
interface Props {
initialRows: RowData[];
}
/**
* Admin queue table. Each row has Approve and Reject buttons.
* Reject opens an inline reason input that must be filled before
* the call goes through (the API also enforces this — empty
* reasons are 400'd server-side).
*
* Actions hit the admin API endpoints, then router.refresh() to
* re-render the server component with the new state (the row
* disappears once flipped to approved/rejected).
*/
export function PendingSkillRequests({ initialRows }: Props) {
const t = useTranslations("adminSkills");
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState("");
// Per-row open-reject-input state. Key = request id.
const [rejectingId, setRejectingId] = useState<string | null>(null);
const [reasonText, setReasonText] = useState("");
const approve = async (id: string) => {
setError("");
setBusyId(id);
try {
const res = await fetch(`/api/admin/skills/pending/${id}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusyId(null);
}
};
const reject = async (id: string) => {
if (!reasonText.trim()) {
setError(t("reasonRequired"));
return;
}
setError("");
setBusyId(id);
try {
const res = await fetch(`/api/admin/skills/pending/${id}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: reasonText }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
setRejectingId(null);
setReasonText("");
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusyId(null);
}
};
if (initialRows.length === 0) {
return (
<Card>
<p className="text-sm text-text-muted italic text-center py-6">
{t("emptyQueue")}
</p>
</Card>
);
}
return (
<Card>
{error && (
<div className="mb-3 p-3 rounded-md border border-error bg-error/10 text-sm text-error">
{error}
</div>
)}
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("requestedAtCol")}</th>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2">{t("tenantCol")}</th>
<th className="pb-2">{t("orgCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
<tbody>
{initialRows.map((row) => (
<Fragment key={row.id}>
<tr className="border-t border-border align-top">
<td className="py-2 text-xs text-text-muted font-mono">
{row.requestedAt.slice(0, 16).replace("T", " ")}
</td>
<td className="py-2">
<div className="font-medium">{row.skillName}</div>
<div className="text-xs text-text-muted font-mono">
{row.skillId}
</div>
</td>
<td className="py-2 font-mono text-xs">{row.tenantName}</td>
<td className="py-2">
<div className="text-xs">{row.companyName ?? "—"}</div>
<div className="text-xs text-text-muted font-mono">
{row.zitadelOrgId.slice(0, 16)}
</div>
</td>
<td className="py-2 text-right">
{rejectingId !== row.id && (
<div className="flex justify-end gap-2">
<button
onClick={() => {
setRejectingId(row.id);
setReasonText("");
setError("");
}}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md border border-error text-error hover:bg-error/10 disabled:opacity-50"
>
{t("rejectBtn")}
</button>
<button
onClick={() => approve(row.id)}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
>
{busyId === row.id ? t("working") : t("approveBtn")}
</button>
</div>
)}
</td>
</tr>
{rejectingId === row.id && (
<tr className="border-t border-border bg-surface-2">
<td colSpan={5} className="py-3 px-3">
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted">
{t("reasonLabel")}
</label>
<textarea
value={reasonText}
onChange={(e) => setReasonText(e.target.value)}
rows={3}
maxLength={1000}
placeholder={t("reasonPlaceholder")}
className="w-full px-3 py-2 rounded-md border border-border bg-surface-1 text-sm"
autoFocus
/>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setRejectingId(null);
setReasonText("");
}}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md border border-border disabled:opacity-50"
>
{t("cancel")}
</button>
<button
onClick={() => reject(row.id)}
disabled={busyId !== null || !reasonText.trim()}
className="text-xs px-3 py-1.5 rounded-md bg-error text-white disabled:opacity-50"
>
{busyId === row.id
? t("working")
: t("confirmRejectBtn")}
</button>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,160 @@
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types";
import { PayInvoiceButton } from "./pay-invoice-button";
import { PaymentStatusBanner } from "./payment-status-banner";
interface Props {
invoice: Invoice;
lines: InvoiceLine[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3",
};
/**
* Read-only invoice detail. Flat list of lines — no per-tenant
* grouping (one invoice per customer; the tenant context is
* already embedded in each line description).
*
* The download link points at /api/billing/invoices/[n]/pdf
* which serves the stored PDF blob inline. Customers using a
* link from their email will hit the same route via this page.
*/
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
return (
<div className="space-y-6 animate-in">
<PaymentStatusBanner />
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="font-display text-2xl font-semibold">
{invoice.invoiceNumber}
</h1>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${invoice.status}` as any)}
</span>
</div>
<p className="text-sm text-text-secondary">
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p>
</div>
<div className="flex items-start gap-2 flex-wrap">
{/* Phase 4: Pay-with-card available for open + overdue.
Paid/void/draft/uncollectible hide the button — the
API also enforces this, so client-side hiding is just
for the visible affordance. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
)}
<a
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
>
{t("downloadPdf")}
</a>
</div>
</div>
<Card>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billedToLabel")}</span>
<span>{invoice.billingSnapshot.companyName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("issuedAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("dueAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
</span>
</div>
{invoice.status === "paid" && invoice.paidAt && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("paidAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
</span>
</div>
)}
</div>
</Card>
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descriptionCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{lines.map((ln) => (
<tr key={ln.id} className="border-t border-border align-top">
<td className="py-2">{ln.description}</td>
<td className="py-2 text-right font-mono text-xs">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-2 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(2)}
</td>
<td className="py-2 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border">
<td colSpan={3} className="pt-3 text-right text-text-muted">
{t("subtotalLabel")}
</td>
<td className="pt-3 text-right font-mono">
{invoice.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-1 text-right text-text-muted">
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
</td>
<td className="pt-1 text-right font-mono">
{invoice.vatAmountChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-2 text-right font-semibold">
{t("totalLabel")}
</td>
<td className="pt-2 text-right font-mono font-semibold text-base">
CHF {invoice.totalChf.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice } from "@/types";
interface Props {
invoices: Invoice[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3 line-through",
};
/**
* Customer's invoice history table. Server component — gets a
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
* to /billing/<invoice-number> for the full detail view.
*
* Columns: number, period, due date, total, status. Status is
* displayed with a colored badge so the customer can scan for
* outstanding ones at a glance.
*/
export function CustomerInvoiceList({ invoices }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
if (invoices.length === 0) {
return (
<Card>
<p className="text-sm text-text-muted italic text-center py-8">
{t("emptyHistory")}
</p>
</Card>
);
}
return (
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("numberCol")}</th>
<th className="pb-2">{t("periodCol")}</th>
<th className="pb-2">{t("dueCol")}</th>
<th className="pb-2 text-right">{t("totalCol")}</th>
<th className="pb-2 text-right">{t("statusCol")}</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr
key={inv.id}
className="border-t border-border hover:bg-surface-2 transition-colors"
>
<td className="py-2">
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-xs text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
CHF {inv.totalChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${inv.status}` as any)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface Props {
invoiceNumber: string;
}
/**
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
* which returns a Stripe Checkout Session URL; we redirect the
* browser there.
*
* The button is rendered only by the parent for status='open' or
* 'overdue' invoices — the API enforces this too, but pre-filtering
* UI-side keeps the dead state out of the customer's face.
*/
export function PayInvoiceButton({ invoiceNumber }: Props) {
const t = useTranslations("customerBilling");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
{ method: "POST" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
if (!data.url) {
throw new Error("Payment session URL missing from response.");
}
// Hard navigation, not Next.js router — Stripe Checkout is a
// separate origin and the browser needs to fully leave our app.
window.location.href = data.url;
} catch (e: any) {
setError(e?.message ?? String(e));
setBusy(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<button
onClick={onClick}
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("redirectingToStripe") : t("payWithCard")}
</button>
{error && (
<span className="text-xs text-error max-w-[260px] text-right">
{error}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
/**
* Banner shown after a return from Stripe Checkout.
*
* ?paid=1 → green success banner. The webhook may or may
* not have processed yet, so we phrase the message
* as "Payment received, status will update shortly"
* and don't claim the status is already paid. A
* light auto-refresh after a few seconds nudges
* the page to pick up the new status badge.
*
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
* invoice stays in 'open' state.
*
* The banner cleans up the query params from the URL so a page
* reload doesn't repeat the message. We use router.replace() to
* keep history clean.
*/
export function PaymentStatusBanner() {
const router = useRouter();
const t = useTranslations("customerBilling");
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has("paid")) {
setState("paid");
// Reload after 4s so the status badge picks up the webhook's
// effect on the invoice row. By then most webhook deliveries
// have landed; if not the user just sees "open" and can
// manually refresh.
const timer = setTimeout(() => {
router.refresh();
}, 4000);
// Strip the query string out of the URL.
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
return () => clearTimeout(timer);
} else if (params.has("cancelled")) {
setState("cancelled");
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
}
}, [router]);
if (state === "paid") {
return (
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
{t("paymentReceived")}
</div>
);
}
if (state === "cancelled") {
return (
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
{t("paymentCancelled")}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceDraft } from "@/types";
type CurrentResponse =
| { issued: Invoice }
| { draft: InvoiceDraft }
| { error: string; code?: string };
/**
* Live running total for the current calendar month.
*
* Loads /api/billing/current on mount. Three result shapes:
*
* - { issued } — current-month invoice already exists; we
* link to it instead of showing a draft total.
* - { draft } — still accruing; show subtotal+VAT+total and
* a small line breakdown.
* - { error } — most likely the org has no billing config
* yet; show a friendly hint, not a stack trace.
*
* Client-side because the compute can take a second or two
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
* No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers.
*/
export function RunningTotalWidget() {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshCounter, setRefreshCounter] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch("/api/billing/current")
.then(async (res) => {
const j = (await res.json()) as CurrentResponse;
if (!cancelled) setData(j);
})
.catch((e) => {
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [refreshCounter]);
if (loading) {
return (
<Card>
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
</Card>
);
}
if (!data || "error" in data) {
return (
<Card>
<p className="text-sm text-text-secondary py-2">
{data && "code" in data && data.code === "COMPUTE_FAILED"
? t("noBillingConfig")
: t("currentPeriodError")}
</p>
</Card>
);
}
if ("issued" in data) {
const inv = data.issued;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-sm text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
<p className="font-mono text-lg font-semibold">
CHF {inv.totalChf.toFixed(2)}
</p>
</div>
</div>
</Card>
);
}
// draft
const draft = data.draft;
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
dateStyle: "long",
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
<div>
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
<p className="text-xs text-text-secondary">{periodLabel}</p>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
<p className="font-mono text-2xl font-semibold text-accent">
CHF {draft.totalChf.toFixed(2)}
</p>
<button
onClick={() => setRefreshCounter((n) => n + 1)}
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
>
{t("refresh")}
</button>
</div>
</div>
{draft.lines.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
{t("breakdownToggle", { count: draft.lines.length })}
</summary>
<table className="w-full mt-2 text-xs">
<tbody>
{draft.lines.map((ln, i) => (
<tr key={i} className="border-t border-border">
<td className="py-1 pr-2">{ln.description}</td>
<td className="py-1 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
<tr className="border-t border-border">
<td className="py-1 pr-2 text-text-muted text-right">
{t("subtotalLabel")}
</td>
<td className="py-1 text-right font-mono">
{draft.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td className="py-1 pr-2 text-text-muted text-right">
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
</td>
<td className="py-1 text-right font-mono">
{draft.vatAmountChf.toFixed(2)}
</td>
</tr>
</tbody>
</table>
</details>
)}
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
</Card>
);
}

View File

@@ -74,6 +74,20 @@ function NavBar() {
{t("settings")}
</NavLink>
)}
{/* Phase 3: Billing visible to anyone signed in. The
page is org-scoped server-side — non-owner members
see the same invoice history their owner does, but
actions like "configure billing details" are gated
separately on the settings page. Personal accounts
see their own (single-tenant) invoices. */}
{user && (
<NavLink
href="/billing"
active={pathname.startsWith("/billing")}
>
{t("billing")}
</NavLink>
)}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}

View File

@@ -1,8 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { PackageDef } from "@/lib/packages";
import type {
SkillActivationRequest,
SkillPricing,
} from "@/types";
import { SkillCostDialog } from "./skill-cost-dialog";
interface Props {
pkg: PackageDef;
@@ -12,6 +18,18 @@ interface Props {
onToggled: () => void;
/** Slice 5: when false, the enable/disable button is hidden. */
canEdit?: boolean;
/**
* Phase 2.5 — most recent non-terminal activation request for this
* skill on this tenant, if any. Drives the "Manual review pending"
* and "Activation rejected" inline states. Approved/withdrawn rows
* never reach the client side.
*/
activationRequest?: SkillActivationRequest | null;
/**
* Phase 2.5 — pricing for this skill if it has any. Triggers the
* cost-disclosure dialog before enable.
*/
pricing?: SkillPricing | null;
}
export function PackageCard({
@@ -21,15 +39,33 @@ export function PackageCard({
tenantName,
onToggled,
canEdit = true,
activationRequest = null,
pricing = null,
}: Props) {
const t = useTranslations();
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Phase 2.5: cost-disclosure flow + activation-request flow.
const [showCostDialog, setShowCostDialog] = useState(false);
const isPriced =
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
async function handleEnable() {
function handleEnable() {
// Phase 2.5: gate priced skills behind the cost-disclosure dialog.
// Confirm → proceedWithEnable. Cancel → bail.
if (isPriced) {
setError(null);
setShowCostDialog(true);
return;
}
void proceedWithEnable();
}
async function proceedWithEnable() {
if (pkg.customProvisioning) {
// Platform-side provisioning, then add to packages list.
setSaving(true);
@@ -112,6 +148,39 @@ export function PackageCard({
}
}
// Phase 2.5: withdraw a still-pending activation request. The
// request row flips to 'withdrawn' (server-side); router.refresh()
// re-renders the tenant page without the pending state, leaving
// the toggle re-enabled if the user wants to retry.
async function withdrawRequest() {
if (!activationRequest || activationRequest.status !== "pending") return;
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/skills/requests/${activationRequest.id}/withdraw`,
{ method: "POST" }
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
// Phase 2.5: retry after a rejection. Same flow as a fresh
// enable; the rejected row stays in the DB as audit trail but a
// new pending row will be created by the PATCH.
function tryAgainAfterRejection() {
setError(null);
handleEnable();
}
async function handleSubmitSecrets() {
if (pkg.disclaimerKey && !accepted) return;
@@ -170,7 +239,50 @@ export function PackageCard({
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
{canEdit ? (
{/* Phase 2.5: pending or rejected request takes precedence
over the toggle. Approved/withdrawn never reach here.
For packages that needed secrets, surface that they're
safely stored — the user might otherwise worry the
credentials they typed got lost when the activation
was deferred. */}
{canEdit && activationRequest?.status === "pending" ? (
<div className="ml-auto flex flex-col items-end gap-1">
<span
className="text-[10px] text-warning italic"
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
>
{t("packages.manualReviewPending")}
{pkg.requiresSecrets && (
<span className="text-text-muted ml-1 not-italic">
· {t("packages.credentialsSaved")}
</span>
)}
</span>
<button
onClick={withdrawRequest}
disabled={saving}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface-3 hover:bg-surface-2 disabled:opacity-50 cursor-pointer"
>
{saving ? "…" : t("packages.withdraw")}
</button>
</div>
) : canEdit && activationRequest?.status === "rejected" ? (
<div className="ml-auto flex flex-col items-end gap-1">
<span
className="text-[10px] text-error italic max-w-[220px] truncate"
title={activationRequest.rejectionReason ?? ""}
>
{t("packages.activationRejected")}: {activationRequest.rejectionReason}
</span>
<button
onClick={tryAgainAfterRejection}
disabled={saving}
className="rounded-lg px-3 py-1.5 text-xs font-medium bg-accent text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
>
{saving ? "…" : t("packages.tryAgain")}
</button>
</div>
) : canEdit ? (
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
@@ -194,6 +306,20 @@ export function PackageCard({
</div>
</div>
{/* Phase 2.5: cost-disclosure modal for priced skills. */}
<SkillCostDialog
open={showCostDialog}
onClose={() => setShowCostDialog(false)}
onConfirm={() => {
setShowCostDialog(false);
void proceedWithEnable();
}}
skillName={pkg.name}
dailyPriceChf={pricing?.dailyPriceChf ?? 0}
setupFeeChf={pricing?.setupFeeChf ?? 0}
busy={saving}
/>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">

View File

@@ -3,6 +3,10 @@
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { PACKAGE_CATALOG } from "@/lib/packages";
import type {
SkillActivationRequest,
SkillPricing,
} from "@/types";
import { PackageCard } from "./package-card";
interface Props {
@@ -12,6 +16,17 @@ interface Props {
onRefresh?: () => void;
/** Slice 5: when false, package toggles and edit affordances are hidden. */
canEdit?: boolean;
/**
* Phase 2.5 — non-terminal activation requests for this tenant.
* Each PackageCard looks up its skill in this array to render the
* pending/rejected inline state. Most recent first.
*/
activationRequests?: SkillActivationRequest[];
/**
* Phase 2.5 — skill pricing keyed by skillId. Drives the cost
* disclosure dialog.
*/
skillPricing?: SkillPricing[];
}
const CATEGORIES = [
@@ -39,11 +54,29 @@ export function PackageList({
conditions,
onRefresh,
canEdit = true,
activationRequests = [],
skillPricing = [],
}: Props) {
const t = useTranslations("packages");
const router = useRouter();
const handleRefresh = onRefresh || (() => router.refresh());
// Build per-skill lookups once so each card render is O(1) rather
// than O(N) over the requests array. `activationRequests` already
// arrives filtered to non-terminal rows (most-recent per
// (skill, status) pair from the server).
const requestBySkill = new Map<string, SkillActivationRequest>();
for (const req of activationRequests) {
// Pending takes precedence over rejected — if both exist for
// the same skill (race or after-rejection-retry), show pending.
const existing = requestBySkill.get(req.skillId);
if (!existing || (existing.status === "rejected" && req.status === "pending")) {
requestBySkill.set(req.skillId, req);
}
}
const pricingBySkill = new Map<string, SkillPricing>();
for (const p of skillPricing) pricingBySkill.set(p.skillId, p);
return (
<div className="space-y-6">
{CATEGORIES.map(({ key, labelKey }) => {
@@ -65,6 +98,8 @@ export function PackageList({
tenantName={tenantName}
onToggled={handleRefresh}
canEdit={canEdit}
activationRequest={requestBySkill.get(pkg.id) ?? null}
pricing={pricingBySkill.get(pkg.id) ?? null}
/>
))}
</div>

View File

@@ -0,0 +1,115 @@
"use client";
import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props {
open: boolean;
onClose: () => void;
onConfirm: () => void;
skillName: string;
dailyPriceChf: number;
setupFeeChf: number;
busy?: boolean;
}
/**
* Cost-disclosure modal shown before activating a priced skill.
*
* Shows the daily rate and setup fee (each only if > 0) and
* requires an explicit Confirm before the activation request goes
* through. Rendered every time the user toggles on a priced skill,
* not once-and-remember — this is recurring-charge consent, not a
* one-time terms agreement.
*
* The setup fee is always shown when configured, with a note
* clarifying it's "one-time, charged on first activation". The
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
* on whether the fee actually fires — we don't second-guess from
* the client. If you've previously activated this skill on this
* tenant, the fee won't appear on the next invoice even though
* the dialog mentions it.
*/
export function SkillCostDialog({
open,
onClose,
onConfirm,
skillName,
dailyPriceChf,
setupFeeChf,
busy = false,
}: Props) {
const t = useTranslations("skillCostDialog");
const showSetupFee = setupFeeChf > 0;
const showDaily = dailyPriceChf > 0;
// Nothing to disclose? Bail to confirm immediately — shouldn't
// normally be shown in this case but guard anyway.
if (!showSetupFee && !showDaily) {
return null;
}
return (
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
<p className="text-sm text-text-secondary mb-4">
{t("intro", { skill: skillName })}
</p>
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
{showSetupFee && (
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("setupFeeLabel")}</div>
<div className="text-xs text-text-muted">
{t("setupFeeNote")}
</div>
</div>
<div className="text-sm font-mono">
CHF {setupFeeChf.toFixed(2)}
</div>
</div>
)}
{showDaily && (
/* Display reference monthly cost (daily × 30) plus the
actual daily rate as a sub-note. Billing is always
per UTC day enabled — partial months prorate to that
same daily rate, full months land at roughly the
figure shown (varies ±~3% by month length). */
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("monthlyPriceLabel")}</div>
<div className="text-xs text-text-muted">
{t("monthlyPriceNote", {
daily: dailyPriceChf.toFixed(2),
})}
</div>
</div>
<div className="text-sm font-mono">
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
</div>
</div>
)}
</div>
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
>
{t("cancel")}
</button>
<button
onClick={onConfirm}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy ? t("confirming") : t("confirm")}
</button>
</div>
</div>
</Modal>
);
}

157
src/lib/billing-i18n.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Shared billing localization. Used by:
* - billing.ts (compute path) — pre-renders the localized
* line description and stores it on the invoice line at issue
* time. Descriptions are then frozen in the customer's locale.
* - billing-pdf.tsx (render path) — can fall back to this if a
* stored description is missing (e.g. legacy invoice from the
* pre-i18n era) or if the PDF is re-rendered in a different
* locale (Phase 7).
*
* Locale set matches the portal's next-intl locales: de, en, fr, it.
* Unknown locales fall back to German (Swiss B2B default).
*/
import type { InvoiceLineKind } from "@/types";
export type BillingLocale = "de" | "en" | "fr" | "it";
function normaliseLocale(locale: string): BillingLocale {
if (locale === "en" || locale === "fr" || locale === "it" || locale === "de") {
return locale;
}
return "de";
}
/**
* Localized "N day(s)" — covers the only plural case in billing
* line descriptions. Other plurals (months, requests, messages)
* either don't change form in the supported languages or are
* always >1 in practice.
*/
function days(n: number, locale: BillingLocale): string {
const labels = {
de: { one: "Tag", many: "Tage" },
en: { one: "day", many: "days" },
fr: { one: "jour", many: "jours" },
it: { one: "giorno", many: "giorni" },
} as const;
const label = labels[locale];
return `${n} ${n === 1 ? label.one : label.many}`;
}
/** Subset of InvoiceLine needed for description formatting. */
export interface LineForDescription {
kind: InvoiceLineKind;
tenantName: string | null;
metadata: Record<string, unknown> | null;
}
/**
* Build the localized line description from a line's kind +
* metadata. Pure function — no DB/IO. Output mirrors what the
* PDF and admin preview show in the description column.
*
* Metadata expectations per kind (must match what billing.ts
* stores when emitting the line):
* tenant_monthly: { billable_days, days_in_month }
* tenant_setup: {} (uses tenantName only)
* ai_usage: { requests }
* threema_messages: { in_count, out_count }
* skill_usage: { skill_id, billable_days }
* skill_setup: { skill_id }
* adjustment: { reason? }
*
* Missing fields fall back to "?" so a malformed line still
* renders something readable rather than crashing the PDF.
*/
export function formatLineDescription(
line: LineForDescription,
locale: string
): string {
const L = normaliseLocale(locale);
const m = line.metadata ?? {};
const tenant = line.tenantName ?? "—";
// Helper to fetch a metadata field with a safe fallback.
const f = (key: string): string | number => {
const v = (m as Record<string, unknown>)[key];
if (v === undefined || v === null) return "?";
return v as string | number;
};
switch (line.kind) {
case "tenant_monthly": {
const bd = f("billable_days");
const dim = f("days_in_month");
return {
de: `Monatliche Grundgebühr für ${tenant} (${bd}/${dim} Tage)`,
en: `Monthly fee for ${tenant} (${bd}/${dim} days)`,
fr: `Forfait mensuel pour ${tenant} (${bd}/${dim} jours)`,
it: `Canone mensile per ${tenant} (${bd}/${dim} giorni)`,
}[L];
}
case "tenant_setup":
return {
de: `Einrichtungsgebühr für ${tenant}`,
en: `Setup fee for ${tenant}`,
fr: `Frais de configuration pour ${tenant}`,
it: `Spese di attivazione per ${tenant}`,
}[L];
case "ai_usage": {
const r = f("requests");
return {
de: `KI-Inferenz-Nutzung (${r} Anfragen)`,
en: `AI inference usage (${r} requests)`,
fr: `Utilisation IA (${r} requêtes)`,
it: `Utilizzo IA (${r} richieste)`,
}[L];
}
case "threema_messages": {
const inC = f("in_count");
const outC = f("out_count");
return {
de: `Threema-Nachrichten (${inC} eingehend + ${outC} ausgehend)`,
en: `Threema messages (${inC} in + ${outC} out)`,
fr: `Messages Threema (${inC} entrants + ${outC} sortants)`,
it: `Messaggi Threema (${inC} in entrata + ${outC} in uscita)`,
}[L];
}
case "skill_usage": {
const skill = f("skill_id");
const bdRaw = (m as Record<string, unknown>)["billable_days"];
const bd = typeof bdRaw === "number" ? bdRaw : 0;
return {
de: `Skill: ${skill} (${days(bd, "de")})`,
en: `Skill: ${skill} (${days(bd, "en")})`,
fr: `Skill: ${skill} (${days(bd, "fr")})`,
it: `Skill: ${skill} (${days(bd, "it")})`,
}[L];
}
case "skill_setup": {
const skill = f("skill_id");
return {
de: `Einrichtungsgebühr Skill: ${skill}`,
en: `Setup fee skill: ${skill}`,
fr: `Frais de configuration skill: ${skill}`,
it: `Spese di attivazione skill: ${skill}`,
}[L];
}
case "adjustment": {
const reasonRaw = (m as Record<string, unknown>)["reason"];
const reason = typeof reasonRaw === "string" ? reasonRaw : null;
const base = {
de: "Anpassung",
en: "Adjustment",
fr: "Ajustement",
it: "Rettifica",
}[L];
return reason ? `${base}: ${reason}` : base;
}
}
}

View File

@@ -125,6 +125,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "KI-Nutzung",
threema_messages: "Threema-Nachrichten",
skill_usage: "Skill-Nutzung",
skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung",
},
reverseCharge:
@@ -156,6 +157,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "AI usage",
threema_messages: "Threema messages",
skill_usage: "Skill usage",
skill_setup: "Skill setup fee",
adjustment: "Adjustment",
},
reverseCharge:
@@ -187,6 +189,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "Utilisation IA",
threema_messages: "Messages Threema",
skill_usage: "Utilisation Skill",
skill_setup: "Frais de configuration skill",
adjustment: "Ajustement",
},
reverseCharge:
@@ -218,6 +221,7 @@ const MESSAGES: Record<string, PdfStrings> = {
ai_usage: "Utilizzo IA",
threema_messages: "Messaggi Threema",
skill_usage: "Utilizzo Skill",
skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica",
},
reverseCharge:

View File

@@ -54,12 +54,15 @@ import {
listSkillPricing,
listSuspensionEventsForTenant,
tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled,
updateInvoicePdf,
} from "./db";
import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
import { sendInvoiceIssuedEmail } from "./email";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
// Period helpers
@@ -370,6 +373,7 @@ async function buildTenantLines(opts: {
daysInMonth: number;
platformPricing: PlatformPricing;
skillPricing: SkillPricing[];
locale: string;
warnings: string[];
displayOrderOffset: number;
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
@@ -380,6 +384,7 @@ async function buildTenantLines(opts: {
daysInMonth,
platformPricing,
skillPricing,
locale,
warnings,
} = opts;
let displayOrder = opts.displayOrderOffset;
@@ -428,19 +433,23 @@ async function buildTenantLines(opts: {
if (billableDays > 0) {
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
const amount = round2(unit * billableDays);
const metadata = {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
};
lines.push({
tenantName,
kind: "tenant_monthly",
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
description: formatLineDescription(
{ kind: "tenant_monthly", tenantName, metadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount,
metadata: {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
},
metadata,
displayOrder: displayOrder++,
});
}
@@ -453,7 +462,10 @@ async function buildTenantLines(opts: {
lines.push({
tenantName,
kind: "tenant_setup",
description: `Setup fee for ${tenantName}`,
description: formatLineDescription(
{ kind: "tenant_setup", tenantName, metadata: null },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: platformPricing.tenantSetupFeeChf,
@@ -480,19 +492,23 @@ async function buildTenantLines(opts: {
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
);
} else if (aiUsage.spendChf > 0) {
const aiMetadata = {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
};
lines.push({
tenantName,
kind: "ai_usage",
description: `AI inference usage (${aiUsage.requestCount} requests)`,
description: formatLineDescription(
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf,
metadata: {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
},
metadata: aiMetadata,
displayOrder: displayOrder++,
});
}
@@ -502,19 +518,23 @@ async function buildTenantLines(opts: {
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
if (threema && (threema.inCount + threema.outCount) > 0) {
const total = threema.inCount + threema.outCount;
const threemaMetadata = {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
};
lines.push({
tenantName,
kind: "threema_messages",
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
description: formatLineDescription(
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
locale
),
quantity: total,
unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf),
metadata: {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
},
metadata: threemaMetadata,
displayOrder: displayOrder++,
});
}
@@ -548,19 +568,48 @@ async function buildTenantLines(opts: {
}
}
if (billableDays > 0) {
// Setup fee fires once per (tenant, skill) — before the
// usage line so it appears above it on the PDF.
if (sp.setupFeeChf > 0) {
const alreadyBilled = await tenantSkillHasBeenBilled(
tenantName,
sp.skillId
);
if (!alreadyBilled) {
const setupMetadata = { skill_id: sp.skillId };
lines.push({
tenantName,
kind: "skill_setup",
description: formatLineDescription(
{ kind: "skill_setup", tenantName, metadata: setupMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: sp.setupFeeChf,
amountChf: round2(sp.setupFeeChf),
metadata: setupMetadata,
displayOrder: displayOrder++,
});
}
}
const skillMetadata = {
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
};
lines.push({
tenantName,
kind: "skill_usage",
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
description: formatLineDescription(
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf),
metadata: {
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
},
metadata: skillMetadata,
displayOrder: displayOrder++,
});
}
@@ -620,6 +669,9 @@ export async function computeInvoiceDraft(opts: {
}
// 4. Build lines, grouped per tenant (display order preserved).
// Locale must be resolved before line construction since the
// descriptions are localized at compute time.
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
let nextDisplayOrder = 0;
// Sort tenants by name for stable line ordering across regenerations.
@@ -632,6 +684,7 @@ export async function computeInvoiceDraft(opts: {
daysInMonth,
platformPricing,
skillPricing,
locale,
warnings,
displayOrderOffset: nextDisplayOrder,
});
@@ -653,9 +706,6 @@ export async function computeInvoiceDraft(opts: {
const paymentMethod: InvoicePaymentMethod =
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
// 7. Locale resolution
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
return {
zitadelOrgId,
periodStart,
@@ -730,6 +780,50 @@ export async function generateInvoice(opts: {
// Pass 2: store the PDF bytes.
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
const finalInvoice = await getInvoiceById(placeholder.id);
// Phase 3: best-effort notification to the billing contact.
// We send AFTER the PDF is fully persisted (so the deep link
// in the email immediately resolves to a downloadable PDF) but
// BEFORE returning, since the cron caller doesn't otherwise
// know to trigger this. Failure is logged, never thrown — a
// mail-server hiccup must not roll back an issued invoice.
// The recipient is the billing email captured in the invoice
// snapshot (immutable; reflects who was on file at issue time).
try {
const settled = finalInvoice ?? placeholder;
const snapshot = settled.billingSnapshot;
if (snapshot.billingEmail) {
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
"en", "de", "fr", "it",
];
const locale = supportedLocales.includes(settled.locale as any)
? (settled.locale as "en" | "de" | "fr" | "it")
: "de";
await sendInvoiceIssuedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName, // no separate contact-name field
companyName: snapshot.companyName,
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf,
currency: "CHF",
dueAt: settled.dueAt,
lineCount: draft.lines.length,
periodStart: settled.periodStart,
periodEnd: settled.periodEnd,
locale,
});
} else {
console.warn(
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
);
}
} catch (e) {
console.error(
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
e
);
}
return { draft, invoice: finalInvoice ?? placeholder };
} catch (e) {
// Render failed — leave the persisted row in place so admin can

View File

@@ -331,6 +331,12 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Phase 2 addition: per-skill one-time setup fee. Charged the
-- first time a given (tenant, skill) appears on an invoice line.
-- Default 0 so pricing rows created before this column exists
-- stay free until the admin sets a fee.
ALTER TABLE skill_pricing
ADD COLUMN IF NOT EXISTS setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
-- One row per tenant. created_at anchors first-month proration;
-- deleted_at (nullable, stamped on delete) anchors last-month
@@ -494,7 +500,7 @@ const MIGRATION_SQL = `
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
tenant_name TEXT,
kind TEXT NOT NULL CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','skill_setup','adjustment'
)),
description TEXT NOT NULL,
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
@@ -524,6 +530,74 @@ const MIGRATION_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
ON invoice_reminders(invoice_id, level);
-- Phase 2.5: queue for skills flagged with requiresManualSetup in
-- the package catalog. A user-side enable on a flagged skill
-- creates a pending row here instead of mutating tenant.spec.packages;
-- the operator never sees the skill until admin approves and adds
-- it to the spec. Disable is always direct — there's no gate on
-- turning a skill off, even one that previously required setup.
CREATE TABLE IF NOT EXISTS skill_activation_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')) DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_at TIMESTAMPTZ,
reviewed_by TEXT,
rejection_reason TEXT,
admin_notes TEXT
);
-- Only one in-flight request per (tenant, skill). Rejected and
-- approved rows don't block new requests; user can retry after a
-- rejection by toggling the skill again.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_skill_act_one_pending
ON skill_activation_requests (tenant_name, skill_id)
WHERE status = 'pending';
-- Admin queue lookup — partial index keeps it tiny.
CREATE INDEX IF NOT EXISTS idx_skill_act_pending_status
ON skill_activation_requests (requested_at DESC)
WHERE status = 'pending';
-- Per-tenant lookup for the customer UI's pending+rejected display.
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
ON skill_activation_requests (tenant_name, requested_at DESC);
-- Phase 3 fix: the original invoice_lines.kind CHECK constraint
-- was created without 'skill_setup' (which Phase 2-fix6 added as
-- a new line kind for per-skill setup fees). CREATE TABLE IF NOT
-- EXISTS doesn't update constraints on existing tables, so we
-- explicitly drop and re-add with the full kind set on every
-- boot. Idempotent — DROP IF EXISTS swallows the not-yet-exists
-- case (fresh installs); ADD always re-creates. Constraint name
-- follows Postgres's default <table>_<column>_check.
ALTER TABLE invoice_lines
DROP CONSTRAINT IF EXISTS invoice_lines_kind_check;
ALTER TABLE invoice_lines
ADD CONSTRAINT invoice_lines_kind_check
CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
'skill_usage','skill_setup','adjustment'
));
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
-- delivery and retries failures with exponential backoff for up to 72h,
-- so the same event.id can arrive multiple times. We insert each
-- event.id with the PK constraint enforcing uniqueness; INSERT either
-- succeeds (first delivery → process the event) or fails with 23505
-- (duplicate → ack with 200 and skip). The payload column is invaluable
-- when diagnosing a webhook that processed wrong; keep it small and
-- prune old rows out-of-band if storage becomes a concern (Phase 7).
CREATE TABLE IF NOT EXISTS stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
payload JSONB
);
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
ON stripe_events (event_type, received_at DESC);
`;
let migrated = false;
@@ -1699,6 +1773,7 @@ function rowToSkillPricing(row: any): SkillPricing {
return {
skillId: row.skill_id,
dailyPriceChf: Number(row.daily_price_chf),
setupFeeChf: Number(row.setup_fee_chf ?? 0),
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
@@ -1724,24 +1799,30 @@ export async function getSkillPricing(
}
/**
* Upsert a daily price for a package. Setting a price activates
* usage-based billing for the (tenant, skill) pair: every UTC day
* the package was enabled in the billing month is one unit on the
* invoice.
* Upsert pricing for a package. `dailyPriceChf` activates
* usage-based billing (one billable unit per UTC day the package
* was enabled). `setupFeeChf` is a one-time charge emitted on the
* first invoice line for any given (tenant, skill).
*
* Both fields are required so admin must consciously set 0 to mean
* "no setup fee" rather than accidentally inheriting an old value
* from a partial update.
*/
export async function setSkillPricing(
skillId: string,
dailyPriceChf: number
dailyPriceChf: number,
setupFeeChf: number
): Promise<SkillPricing> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO skill_pricing (skill_id, daily_price_chf)
VALUES ($1, $2)
`INSERT INTO skill_pricing (skill_id, daily_price_chf, setup_fee_chf)
VALUES ($1, $2, $3)
ON CONFLICT (skill_id) DO UPDATE SET
daily_price_chf = EXCLUDED.daily_price_chf,
setup_fee_chf = EXCLUDED.setup_fee_chf,
updated_at = now()
RETURNING *`,
[skillId, dailyPriceChf]
[skillId, dailyPriceChf, setupFeeChf]
);
return rowToSkillPricing(result.rows[0]);
}
@@ -2361,6 +2442,38 @@ export async function getInvoiceDetail(
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Phase 3 — customer-scoped lookup by human-readable invoice
* number with ownership enforcement in a single query. The org
* filter is part of the WHERE clause so a customer can't probe
* another org's invoice numbers (which are sequential and easy
* to guess) and get a different status code (404 vs 403) than
* for their own — both miss-and-not-yours return null.
*
* Used by /api/billing/invoices/[invoiceNumber] and the
* /billing/[invoiceNumber] customer page.
*/
export async function getInvoiceByNumberForOrg(
invoiceNumber: string,
zitadelOrgId: string
): Promise<InvoiceDetail | null> {
await ensureSchema();
const head = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
WHERE invoice_number = $1 AND zitadel_org_id = $2
LIMIT 1`,
[invoiceNumber, zitadelOrgId]
);
if (head.rows.length === 0) return null;
const invoice = rowToInvoice(head.rows[0]);
const lines = await getPool().query(
`SELECT * FROM invoice_lines WHERE invoice_id = $1
ORDER BY display_order, id`,
[invoice.id]
);
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
* stored (shouldn't happen in v1; defensive against partial state).
@@ -2500,6 +2613,32 @@ export async function tenantHasSetupFeeBilled(
return result.rows.length > 0;
}
/**
* Has this (tenant, skill) pair already appeared on any prior
* invoice line — either as setup or usage? Drives the per-skill
* setup-fee gate. Same "first appearance" semantics as the tenant
* setup fee: a previously-free skill that newly gets a setup fee
* configured will trigger the fee on its next billed period.
*
* Uses metadata->>'skill_id' (which is what both skill_setup and
* skill_usage lines store) rather than parsing description.
*/
export async function tenantSkillHasBeenBilled(
tenantName: string,
skillId: string
): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`SELECT 1 FROM invoice_lines
WHERE tenant_name = $1
AND kind IN ('skill_setup', 'skill_usage')
AND metadata->>'skill_id' = $2
LIMIT 1`,
[tenantName, skillId]
);
return result.rows.length > 0;
}
/**
* Aggregate open balance per org for the admin overview. Returns
* orgs with at least one open or overdue invoice; orgs in good
@@ -2551,3 +2690,274 @@ export async function updateInvoicePdf(
[invoiceId, pdfBuffer, filename]
);
}
// ---------------------------------------------------------------------------
// Skill activation requests — Phase 2.5
// ---------------------------------------------------------------------------
import type {
SkillActivationRequest,
SkillActivationStatus,
} from "@/types";
function rowToSkillActivationRequest(row: any): SkillActivationRequest {
return {
id: row.id,
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
skillId: row.skill_id,
status: row.status as SkillActivationStatus,
requestedAt: row.requested_at?.toISOString?.() ?? row.requested_at,
reviewedAt: row.reviewed_at?.toISOString?.() ?? row.reviewed_at ?? null,
reviewedBy: row.reviewed_by ?? null,
rejectionReason: row.rejection_reason ?? null,
adminNotes: row.admin_notes ?? null,
};
}
/**
* Insert a pending activation request. Throws a tagged error if a
* pending row already exists for the (tenant, skill) — the partial
* unique index enforces this. The caller surfaces "request already
* pending" to the user rather than letting it 500.
*/
export async function createSkillActivationRequest(params: {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
skillId: string;
}): Promise<SkillActivationRequest> {
await ensureSchema();
try {
const result = await getPool().query(
`INSERT INTO skill_activation_requests
(tenant_name, zitadel_org_id, zitadel_user_id, skill_id)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
params.tenantName,
params.zitadelOrgId,
params.zitadelUserId,
params.skillId,
]
);
return rowToSkillActivationRequest(result.rows[0]);
} catch (e: any) {
if (e?.code === "23505") {
const err = new Error(
`A pending activation request already exists for ${params.skillId} on ${params.tenantName}.`
);
(err as any).code = "REQUEST_ALREADY_PENDING";
throw err;
}
throw e;
}
}
export async function getSkillActivationRequestById(
id: string
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM skill_activation_requests WHERE id = $1",
[id]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}
/**
* All pending requests across all tenants — feeds the admin queue
* page. Capped to 500 rows for safety; unlikely to ever be hit but
* keeps the page bounded.
*/
export async function listPendingSkillActivationRequests(): Promise<
SkillActivationRequest[]
> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM skill_activation_requests
WHERE status = 'pending'
ORDER BY requested_at ASC
LIMIT 500`
);
return result.rows.map(rowToSkillActivationRequest);
}
export async function countPendingSkillActivationRequests(): Promise<number> {
await ensureSchema();
const result = await getPool().query(
"SELECT COUNT(*)::int AS c FROM skill_activation_requests WHERE status = 'pending'"
);
return result.rows[0]?.c ?? 0;
}
/**
* Requests visible to a customer for one tenant. Returns:
* - pending: rows the user might want to withdraw
* - rejected: the most recent rejection per skill, so the user
* sees why and can retry
* Approved and withdrawn rows are excluded (terminal states with
* no user-visible UI effect after the fact).
*/
export async function listSkillActivationRequestsForTenant(
tenantName: string
): Promise<SkillActivationRequest[]> {
await ensureSchema();
const result = await getPool().query(
`WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skill_id, status
ORDER BY requested_at DESC
) AS rn
FROM skill_activation_requests
WHERE tenant_name = $1
AND status IN ('pending', 'rejected')
)
SELECT * FROM ranked WHERE rn = 1
ORDER BY requested_at DESC`,
[tenantName]
);
return result.rows.map(rowToSkillActivationRequest);
}
/**
* Transition a request's status. The caller is responsible for the
* side effects (K8s patch on approve, email send) — this function
* only touches the row.
*
* Returns null when the row doesn't exist or isn't in 'pending'
* status. That null is meaningful: it tells the caller the
* transition didn't happen (already approved/rejected by another
* admin tab, etc.) and downstream actions should be skipped.
*/
export async function updateSkillActivationRequestStatus(
id: string,
newStatus: Exclude<SkillActivationStatus, "pending">,
opts: {
reviewedBy: string;
rejectionReason?: string | null;
adminNotes?: string | null;
}
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
`UPDATE skill_activation_requests
SET status = $2,
reviewed_at = now(),
reviewed_by = $3,
rejection_reason = $4,
admin_notes = COALESCE($5, admin_notes)
WHERE id = $1
AND status = 'pending'
RETURNING *`,
[id, newStatus, opts.reviewedBy, opts.rejectionReason ?? null, opts.adminNotes ?? null]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}
// ---------------------------------------------------------------------------
// Phase 3 diagnostic — single-purpose helper for the /api/admin/billing/debug
// endpoint. Returns raw invoice_line rows for a tenant filtered to setup-fee
// rows, so a human can verify what the billing emission code's SQL is
// actually seeing. Not intended for production use; kept here for shipping
// hotfixes when running-total drafts diverge from expected behaviour.
// ---------------------------------------------------------------------------
export async function debugListSetupLines(
tenantName: string
): Promise<Array<{
id: string;
invoice_id: string;
tenant_name: string;
kind: string;
amount_chf: number;
description: string;
}>> {
await ensureSchema();
const result = await getPool().query(
`SELECT id, invoice_id, tenant_name, kind, amount_chf, description
FROM invoice_lines
WHERE tenant_name = $1
AND kind = 'tenant_setup'`,
[tenantName]
);
return result.rows;
}
// ---------------------------------------------------------------------------
// Stripe — Phase 4
// ---------------------------------------------------------------------------
/**
* Attempt to record receipt of a Stripe webhook event. Returns true
* when this is the first time we've seen the event (caller should
* process it), false when the event_id was already present
* (caller should ack with 200 and skip — Stripe retries are
* normal and we must be idempotent).
*
* The whole-payload JSONB is stored so a misbehaving event can be
* diagnosed after the fact without re-fetching from Stripe.
*/
export async function tryRecordStripeEvent(
eventId: string,
eventType: string,
payload: unknown
): Promise<boolean> {
await ensureSchema();
try {
await getPool().query(
`INSERT INTO stripe_events (event_id, event_type, payload)
VALUES ($1, $2, $3::jsonb)`,
[eventId, eventType, JSON.stringify(payload)]
);
return true;
} catch (e: any) {
// 23505 = unique_violation; the row already exists, meaning we've
// seen this event before. That's the normal duplicate-delivery
// case — return false so the caller short-circuits.
if (e?.code === "23505") return false;
throw e;
}
}
/**
* Stamp processed_at on a stripe_events row once the handler has
* finished its work successfully. Lets us spot stuck events
* (received but not processed) for diagnosis.
*/
export async function markStripeEventProcessed(eventId: string): Promise<void> {
await ensureSchema();
await getPool().query(
"UPDATE stripe_events SET processed_at = now() WHERE event_id = $1",
[eventId]
);
}
/**
* Persist the Stripe PaymentIntent id on an invoice. Used by the
* webhook handler once the Checkout Session completes — at that
* point Stripe has minted the PaymentIntent and we want to be
* able to find the Stripe-side record from the invoice (and vice
* versa via metadata).
*
* Idempotent: re-running with the same value is a no-op. The
* column was added in Phase 2 schema; this helper was missing.
*/
export async function setInvoiceStripePaymentIntent(
invoiceId: string,
paymentIntentId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE invoices
SET stripe_payment_intent_id = $2
WHERE id = $1
AND (stripe_payment_intent_id IS NULL OR stripe_payment_intent_id = $2)`,
[invoiceId, paymentIntentId]
);
}

View File

@@ -723,3 +723,294 @@ export async function sendSupportAdminNotificationEmail(params: {
console.error("Failed to send admin support notification:", err);
}
}
// ---------------------------------------------------------------------------
// Skill activation requests — Phase 2.5
// ---------------------------------------------------------------------------
//
// Three notifications:
//
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
// when a customer requests a
// flagged skill.
//
// sendSkillActivationApprovalEmail — to the customer, on approve.
//
// sendSkillActivationRejectionEmail — to the customer, on reject,
// including the admin's reason.
//
// All three follow the existing patterns in this file (HTML + plaintext,
// escaped vars, best-effort with errors logged not thrown).
/**
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
* requested activation of a manual-setup skill. The skill name +
* tenant + requester are all included so admin can act without
* loading the portal.
*/
export async function sendSkillActivationAdminNotification(params: {
tenantName: string;
skillId: string;
skillName: string;
requesterEmail: string;
requesterName: string;
companyName: string | null;
}): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
const safeTenant = escapeHtml(params.tenantName);
const safeSkillId = escapeHtml(params.skillId);
const safeSkillName = escapeHtml(params.skillName);
const safeRequester = escapeHtml(params.requesterName);
const safeRequesterEmail = escapeHtml(params.requesterEmail);
const safeCompany = params.companyName
? escapeHtml(params.companyName)
: "—";
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
text: [
"A customer has requested activation of a manual-setup skill.",
"",
`Skill: ${params.skillName} (${params.skillId})`,
`Tenant: ${params.tenantName}`,
`Organization:${params.companyName ?? "—"}`,
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
"",
"Review and act in the admin queue:",
"https://app.pieced.ch/admin/skills/pending",
].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: #10B981;">Skill activation requested</h2>
<p>A customer has requested activation of a manual-setup skill.</p>
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} &lt;${safeRequesterEmail}&gt;</td></tr>
</table>
<p>
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
Open admin queue
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT — Admin notification</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation admin notification:", err);
}
}
export async function sendSkillActivationApprovalEmail(params: {
to: string;
contactName: string;
skillName: string;
tenantName: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeSkill = escapeHtml(params.skillName);
const safeTenant = escapeHtml(params.tenantName);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: `Your skill activation has been approved — ${params.skillName}`,
text: [
`Hello ${params.contactName},`,
"",
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
"",
"You can manage it from your tenant settings.",
"",
"Best regards,",
"PieCed IT",
].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:#10B981;">Skill approved & activated</h2>
<p>Hello ${safeName},</p>
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
<p>You can manage it from your tenant settings.</p>
<p>
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
Open tenant
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation approval email:", err);
}
}
export async function sendSkillActivationRejectionEmail(params: {
to: string;
contactName: string;
skillName: string;
tenantName: string;
reason: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeSkill = escapeHtml(params.skillName);
const safeTenant = escapeHtml(params.tenantName);
const safeReason = escapeHtml(params.reason);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: `Update on your skill activation request — ${params.skillName}`,
text: [
`Hello ${params.contactName},`,
"",
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
"",
"Reason from our team:",
params.reason,
"",
"You can try again from your tenant settings once the matter is resolved.",
"",
"Best regards,",
"PieCed IT",
].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:#ef4444;">Activation request not approved</h2>
<p>Hello ${safeName},</p>
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
<div style="background:#2a2a2a; border-left:3px solid #ef4444; padding:12px 16px; border-radius:6px; margin:16px 0;">
<p style="color:#ccc; font-size:13px; margin:0;"><strong>Reason from our team:</strong></p>
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
</div>
<p>You can try again from your tenant settings once the matter is resolved.</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation rejection email:", err);
}
}
// ---------------------------------------------------------------------------
// Invoice issuance — Phase 3
// ---------------------------------------------------------------------------
/**
* Notify the billing contact when a new invoice has been issued.
* Includes a brief summary (total + due date + line count) so the
* recipient can triage without opening the portal, plus a deep
* link to /billing/<invoice number> where they can download the
* PDF. The PDF itself is NOT attached — it lives in the portal,
* keeps mail payloads small, and avoids the audit-trail headache
* of "which copy is authoritative".
*/
export async function sendInvoiceIssuedEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string; // "CHF" — passed for future-proofing
dueAt: string; // ISO date
lineCount: number;
periodStart: string; // ISO date
periodEnd: string; // ISO date
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
// All four locales — the email is sent in the invoice's locale,
// which was frozen at issue time. No fallback to admin's locale.
const L = params.locale;
const subjectsByLocale: Record<typeof L, string> = {
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
const introByLocale: Record<typeof L, string> = {
en: `A new invoice has been issued for ${params.companyName}.`,
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
};
const labels: Record<typeof L, Record<string, string>> = {
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
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 periodFmt = `${params.periodStart.slice(0, 10)}${params.periodEnd.slice(0, 10)}`;
const dueFmt = params.dueAt.slice(0, 10);
// Both bodies built in the invoice's locale.
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: subjectsByLocale[L],
text: [
greetingsByLocale[L],
"",
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.period}: ${periodFmt}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.lines}: ${params.lineCount}`,
"",
`${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: #10B981;">${escapeHtml(introByLocale[L])}</h2>
<p>${escapeHtml(greetingsByLocale[L])}</p>
<p>${escapeHtml(introByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; 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.lines}</td><td>${params.lineCount}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; 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 invoice issued email:", err);
}
}

View File

@@ -57,6 +57,25 @@ export interface PackageDef {
* that the customer is not aware of.
*/
customProvisioning?: boolean;
/**
* When true, customer-initiated enable requests are routed through
* an admin approval queue (skill_activation_requests) instead of
* being applied immediately. Platform-side manual work (hardware
* provisioning, third-party account setup, DNS, etc.) happens
* between request and approval, so we keep the tenant out of the
* spec until that work is done and the operator would otherwise
* fail to reconcile.
*
* Platform admins bypass the gate (direct PATCH from /admin still
* applies immediately). Disable is always direct — there's no
* gate on turning a skill off.
*
* Orthogonal to `requiresSecrets` and `customProvisioning`. A skill
* can have all three: customer provides credentials, the secrets
* are stored, the activation request lands in the admin queue,
* admin does the manual work, then approves.
*/
requiresManualSetup?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
@@ -212,6 +231,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
},
{
id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,

260
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* Server-side Stripe client + helpers for Phase 4 (card payments).
*
* Architecture (see Phase 4 notes):
* 1. Customer clicks "Pay with card" on /billing/<number>.
* 2. Server creates Stripe Checkout Session (mode='payment') with
* the invoice total as a single line item. We pass `customer`
* to reuse an existing Stripe Customer if the org already has
* one, otherwise we create one and persist its id in
* org_billing_config.stripe_customer_id.
* 3. Returns session.url; the browser redirects there.
* 4. Customer pays; Stripe redirects to success_url with the
* session id appended.
* 5. /api/stripe/webhook receives `checkout.session.completed`,
* verifies signature, looks up the invoice id from metadata,
* flips the invoice to 'paid'.
*
* Env vars:
* STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod
* STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_...
* APP_BASE_URL (required) - e.g. https://app.pieced.ch
*
* SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia.
* Pinning the API version means a `npm update` of the SDK won't
* silently change request/response shapes; we explicitly bump when
* we want a new API version.
*/
import Stripe from "stripe";
import type { Invoice } from "@/types";
// Pinned API version. `as const` narrows this to a string-literal
// type that the Stripe constructor's `apiVersion` field accepts
// exactly. When the installed SDK bumps to a new pinned version,
// TypeScript will surface the mismatch at the `new Stripe(...)` call
// below — bump this string deliberately alongside the SDK upgrade
// and review the API changelog before doing so.
const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
// Cache the client across hot reloads / serverless invocations.
// We don't instantiate at module load because some build steps run
// without runtime env vars set — only fail when actually used.
let cachedClient: Stripe | null = null;
export function getStripeClient(): Stripe {
if (cachedClient) return cachedClient;
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error(
"STRIPE_SECRET_KEY is not set. Configure it in your environment."
);
}
cachedClient = new Stripe(key, {
apiVersion: STRIPE_API_VERSION,
// Identify ourselves in Stripe's request logs so support can
// distinguish PieCed traffic from other integrations on the
// same account.
appInfo: {
name: "PieCed Portal",
version: "1.0.0",
url: "https://app.pieced.ch",
},
});
return cachedClient;
}
/**
* Return the configured webhook secret. Separated so the webhook
* handler can fail fast with a clear error message rather than the
* generic "STRIPE_SECRET_KEY missing" path above.
*/
export function getWebhookSecret(): string {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new Error(
"STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard."
);
}
return secret;
}
/**
* Convert a CHF decimal amount (e.g. 123.45) to integer rappen
* (e.g. 12345). Stripe API requires integer amounts in the
* currency's smallest unit. Centralised so we don't have rounding
* drift between callers.
*/
export function chfToRappen(amountChf: number): number {
// toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004).
return Math.round(parseFloat(amountChf.toFixed(2)) * 100);
}
/**
* Look up or create the Stripe Customer for a PieCed org.
*
* Lazy creation: orgs that only pay by invoice never get a Stripe
* Customer. The first "Pay with card" click triggers creation; the
* id is persisted in org_billing_config so subsequent invoices
* reuse it.
*
* Returns the Stripe customer id (`cus_...`).
*/
export async function ensureStripeCustomerForOrg(params: {
zitadelOrgId: string;
// Snapshot taken at click-time, NOT at invoice issuance — the
// org's current address goes on the Stripe customer object.
// Stripe's address on file is independent of any one invoice.
companyName: string;
billingEmail: string;
address: {
line1: string;
postalCode: string;
city: string;
country: string; // ISO 3166-1 alpha-2 (e.g. "CH")
};
}): Promise<string> {
// Lazy import to avoid pulling pg into edge-runtime modules that
// might import this file. Same pattern used elsewhere in lib/.
const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db");
const existing = await getOrgBillingConfig(params.zitadelOrgId);
if (existing.stripeCustomerId) {
return existing.stripeCustomerId;
}
const stripe = getStripeClient();
const customer = await stripe.customers.create({
email: params.billingEmail,
name: params.companyName,
address: {
line1: params.address.line1,
postal_code: params.address.postalCode,
city: params.address.city,
country: params.address.country,
},
metadata: {
zitadel_org_id: params.zitadelOrgId,
},
});
await updateOrgBillingConfig(params.zitadelOrgId, {
stripeCustomerId: customer.id,
});
return customer.id;
}
/**
* Create a Checkout Session for paying a single invoice by card.
*
* Design notes:
*
* - Single line item with the invoice total (gross, VAT included).
* Our own invoice PDF already breaks down lines + VAT; the Stripe
* page is the checkout, not a duplicate of the invoice.
*
* - `automatic_tax` is disabled because the invoice already has
* VAT computed by our pipeline. Letting Stripe re-calculate
* would double-charge or contradict our PDF.
*
* - `payment_method_types` is NOT set, so Stripe surfaces dynamic
* payment methods configured on the account (cards, TWINT for
* Swiss customers, Apple Pay, Google Pay, etc.) automatically.
*
* - `metadata` and `payment_intent_data.metadata` BOTH carry the
* invoice id. The session-level copy is enough for the
* `checkout.session.completed` webhook; the intent-level copy
* lets us correlate refunds and disputes which fire on the
* PaymentIntent and don't include session metadata.
*
* - `client_reference_id` is set to our invoice id as a stable
* reference. Visible in the Stripe dashboard, useful for support.
*
* - `locale` follows the invoice's locale so the customer sees
* the Stripe page in their language (frozen at invoice issue
* time; consistent with PDF + email).
*/
export async function createCheckoutSessionForInvoice(params: {
invoice: Invoice;
customerId: string;
baseUrl: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { invoice, customerId, baseUrl } = params;
// Stripe Checkout supports a limited set of locales; map our
// four to Stripe's codes and fall back to 'auto' if anything
// outside the set ever appears.
//
// We deliberately don't annotate this with
// `Stripe.Checkout.SessionCreateParams.Locale` — stripe-node v22
// ships with a known type-export regression
// (stripe/stripe-node#2662) where params types under namespaced
// resources aren't re-exported from the resource barrel. The
// `as const` literal narrowing gives the variable the union type
// `"de" | "fr" | "it" | "en" | "auto"`, which `sessions.create`
// accepts at the call site via its own inline parameter typing.
// When the SDK fixes the re-export, we can put the annotation
// back without touching the call site.
const stripeLocale =
invoice.locale === "de"
? ("de" as const)
: invoice.locale === "fr"
? ("fr" as const)
: invoice.locale === "it"
? ("it" as const)
: invoice.locale === "en"
? ("en" as const)
: ("auto" as const);
const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`;
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
client_reference_id: invoice.id,
locale: stripeLocale,
line_items: [
{
quantity: 1,
price_data: {
currency: "chf",
unit_amount: chfToRappen(invoice.totalChf),
product_data: {
name: `Invoice ${invoice.invoiceNumber}`,
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)}${invoice.periodEnd.slice(0, 10)}`,
},
},
},
],
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
payment_intent_data: {
// Mirror invoice id at the PaymentIntent level so refunds &
// disputes (which fire on the PI, not the session) can be
// correlated to our invoice without an extra lookup.
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
// Statement descriptor shown on the customer's card
// statement. Limited to 22 chars total; we use the prefix
// since Stripe will prepend the merchant name from the
// account anyway. Keep it short and recognisable.
description: `Invoice ${invoice.invoiceNumber}`,
},
success_url: successUrl,
cancel_url: cancelUrl,
// VAT is already in invoice.totalChf — don't let Stripe touch tax.
automatic_tax: { enabled: false },
});
if (!session.url) {
throw new Error(
`Stripe returned a session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Einstellungen",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Abrechnung"
},
"login": {
"title": "PieCed Portal",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh-Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Die Google-Workspace-Integration verwendet OAuth und erfordert derzeit manuelles Onboarding. Bitte eröffnen Sie ein Support-Ticket, um den Setup-Prozess zu starten — wir tauschen die Client-Zugangsdaten und ein Refresh-Token offline aus und aktivieren dann dieses Paket für Ihren Mandanten.",
"instructions": "Google Workspace nutzt OAuth. Erstellen Sie einen OAuth-Client in Ihrem Google-Cloud-Projekt, autorisieren Sie ihn mit den benötigten Scopes (Gmail, Kalender, Drive usw.) und fügen Sie die Zugangsdaten unten ein. Mit dem Absenden werden sie sicher gespeichert und Ihre Aktivierung zur Admin-Prüfung eingereiht — nach Genehmigung wird die Integration automatisch aktiviert.",
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
},
"mail": {
@@ -311,7 +312,13 @@
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
}
},
"manualReviewPending": "Manuelle Prüfung ausstehend",
"withdraw": "Zurückziehen",
"activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert",
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
},
"admin": {
"title": "Plattform-Admin",
@@ -385,7 +392,8 @@
"resumeRequestBadge": "Wieder",
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →"
"billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -586,17 +594,17 @@
"save": "Speichern",
"saving": "Speichere…",
"savedOk": "Gespeichert",
"skillPricingTitle": "Skill-Preise",
"skillPricingDesc": "Tagespreis pro Skill. Ein zu beliebigem Zeitpunkt an einem UTC-Tag aktivierter Skill zählt als ein abrechenbarer Tag.",
"skillCol": "Skill",
"skillPricingTitle": "Paket-Preise",
"skillPricingDesc": "Tagespreis und einmalige Einrichtungsgebühr für jedes Paket — Core, Kanal oder Skill. Die Preisgestaltung gilt für jeden Tenant, der das Paket aktiviert.",
"skillCol": "Paket",
"dailyPriceCol": "Tagespreis",
"actionsCol": "",
"remove": "Entfernen",
"noSkillsPriced": "Noch keine Skills bepreist.",
"addSkillLabel": "Skill hinzufügen",
"noSkillsPriced": "Noch keine Pakete bepreist.",
"addSkillLabel": "Paket hinzufügen",
"dailyPriceLabel": "Tagespreis",
"add": "Hinzufügen",
"confirmDeleteSkillPrice": "Preis für {skill} entfernen?",
"confirmDeleteSkillPrice": "Preisgestaltung für {skill} entfernen? Bereits abgerechnete Zeiträume bleiben unberührt.",
"clickToEdit": "Zum Bearbeiten klicken",
"generateFormTitle": "Rechnung erstellen",
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
@@ -653,6 +661,86 @@
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
"paidOnLabel": "Bezahlt am",
"lineItemsTitle": "Positionen",
"billToSnapshotTitle": "Rechnungsempfänger"
"billToSnapshotTitle": "Rechnungsempfänger",
"setupFeeCol": "Einrichtungsgebühr",
"skillSetupFeeLabel": "Einrichtungsgebühr"
},
"skillCostDialog": {
"title": "Aktivierungskosten bestätigen",
"intro": "Die Aktivierung von {skill} verursacht folgende Kosten:",
"setupFeeLabel": "Einrichtungsgebühr",
"setupFeeNote": "Einmalig, nur bei erster Aktivierung",
"monthlyPriceLabel": "Monatspreis",
"monthlyPriceNote": "CHF {daily}/Tag aktiv; Teilmonate werden taggenau berechnet",
"monthUnit": "Monat",
"disclaimer": "Diese Kosten erscheinen auf Ihrer nächsten Monatsrechnung. Mit der Bestätigung stimmen Sie ihnen zu.",
"cancel": "Abbrechen",
"confirm": "Bestätigen & aktivieren",
"confirming": "Aktiviere…"
},
"adminSkills": {
"title": "Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Pakete, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"backToAdmin": "Zurück zur Verwaltung",
"emptyQueue": "Keine ausstehenden Skill-Aktivierungsanfragen.",
"requestedAtCol": "Angefragt",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organisation",
"actionsCol": "",
"approveBtn": "Genehmigen",
"rejectBtn": "Ablehnen",
"confirmRejectBtn": "Ablehnung bestätigen",
"working": "Arbeite…",
"cancel": "Abbrechen",
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
"reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.",
"reasonRequired": "Ein Grund ist für die Ablehnung erforderlich."
},
"customerBilling": {
"title": "Abrechnung",
"subtitle": "Aktueller Zeitraum und Rechnungshistorie. Ausgestellte Rechnungen stehen als PDF-Download bereit.",
"backToBilling": "Zurück zur Abrechnung",
"currentPeriodHeading": "Aktueller Zeitraum",
"historyHeading": "Rechnungshistorie",
"computing": "Berechne aktuellen Periodenbetrag…",
"currentPeriodError": "Aktueller Periodenbetrag konnte nicht geladen werden. Bitte später erneut versuchen.",
"noBillingConfig": "Abrechnungsdaten sind noch nicht hinterlegt. Sobald die Rechnungsadresse Ihrer Organisation eingetragen ist, erscheint hier der laufende Betrag.",
"accruedSoFar": "Bisher in diesem Monat",
"estimatedTotal": "Geschätzter Gesamtbetrag",
"currentInvoiceIssued": "Aktueller Monat bereits abgerechnet",
"refresh": "aktualisieren",
"breakdownToggle": "Aufschlüsselung anzeigen ({count} Positionen)",
"draftNote": "Live-Schätzung. Die endgültige Rechnung kann durch Monatsendrundung, nachgemeldete Nutzungsdaten oder manuelle Anpassungen leicht abweichen.",
"emptyHistory": "Noch keine Rechnungen ausgestellt. Nach Abschluss Ihres ersten Monats erscheinen sie hier.",
"numberCol": "Nummer",
"periodCol": "Zeitraum",
"dueCol": "Fällig",
"totalCol": "Gesamt",
"statusCol": "Status",
"descriptionCol": "Beschreibung",
"qtyCol": "Menge",
"unitCol": "Einzelpreis",
"amountCol": "Betrag",
"billedToLabel": "Rechnungsempfänger",
"issuedAtLabel": "Ausgestellt",
"dueAtLabel": "Zahlbar bis",
"paidAtLabel": "Bezahlt am",
"subtotalLabel": "Zwischensumme",
"vatLabel": "MWST ({rate}%)",
"totalLabel": "Gesamt",
"downloadPdf": "PDF herunterladen",
"status": {
"draft": "Entwurf",
"open": "Offen",
"paid": "Bezahlt",
"overdue": "Überfällig",
"void": "Storniert",
"uncollectible": "Uneinbringlich"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank. Der Status wird aktualisiert, sobald Stripe bestätigt (wenige Sekunden).",
"paymentCancelled": "Zahlung wurde abgebrochen. Die Rechnung ist weiterhin offen; Sie können es jederzeit erneut versuchen."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Settings",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Billing"
},
"login": {
"title": "PieCed Portal",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace integration uses OAuth and requires manual onboarding for now. Please open a support ticket to start the setup — we'll exchange the client credentials and a refresh token offline, then enable this package on your tenant.",
"instructions": "Google Workspace uses OAuth. Create an OAuth client in your Google Cloud project, authorize it with the scopes you need (Gmail, Calendar, Drive, etc.), then paste the credentials below. Submission stores them securely and queues your activation for admin review — once approved, the integration activates automatically.",
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
},
"mail": {
@@ -311,7 +312,13 @@
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
}
},
"manualReviewPending": "Manual review pending",
"withdraw": "Withdraw",
"activationRejected": "Rejected",
"tryAgain": "Try again",
"credentialsSaved": "credentials saved",
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
},
"admin": {
"title": "Platform Admin",
@@ -385,7 +392,8 @@
"resumeRequestBadge": "Resume",
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →"
"billingTool": "Billing →",
"skillsQueueTool": "Activation Queue"
},
"channelUsers": {
"title": "Authorized Users",
@@ -586,17 +594,17 @@
"save": "Save",
"saving": "Saving…",
"savedOk": "Saved",
"skillPricingTitle": "Skill pricing",
"skillPricingDesc": "Per-skill daily price. A skill enabled at any point during a UTC day counts as one billable day.",
"skillCol": "Skill",
"skillPricingTitle": "Package pricing",
"skillPricingDesc": "Set per-day rate and one-time setup fee for any package — core, channel, or skill. Pricing applies to every tenant that enables the package.",
"skillCol": "Package",
"dailyPriceCol": "Daily price",
"actionsCol": "",
"remove": "Remove",
"noSkillsPriced": "No skills are priced yet.",
"addSkillLabel": "Add skill",
"noSkillsPriced": "No packages priced yet.",
"addSkillLabel": "Add package",
"dailyPriceLabel": "Daily price",
"add": "Add",
"confirmDeleteSkillPrice": "Remove pricing for {skill}?",
"confirmDeleteSkillPrice": "Remove pricing for {skill}? Already-billed periods are unaffected.",
"clickToEdit": "Click to edit",
"generateFormTitle": "Generate invoice",
"noOrgsToGenerate": "No organizations with tenants found.",
@@ -653,6 +661,86 @@
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
"paidOnLabel": "Paid",
"lineItemsTitle": "Line items",
"billToSnapshotTitle": "Billed to"
"billToSnapshotTitle": "Billed to",
"setupFeeCol": "Setup fee",
"skillSetupFeeLabel": "Setup fee"
},
"skillCostDialog": {
"title": "Confirm activation cost",
"intro": "Activating {skill} will incur the following charges:",
"setupFeeLabel": "Setup fee",
"setupFeeNote": "One-time, charged on first activation only",
"monthlyPriceLabel": "Monthly price",
"monthlyPriceNote": "CHF {daily}/day enabled; partial months prorated by day",
"monthUnit": "month",
"disclaimer": "These charges appear on your next monthly invoice. By confirming you agree to incur them.",
"cancel": "Cancel",
"confirm": "Confirm & activate",
"confirming": "Activating…"
},
"adminSkills": {
"title": "Activation queue",
"subtitle": "Customer requests to activate packages that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"backToAdmin": "Back to Admin",
"emptyQueue": "No pending skill activation requests.",
"requestedAtCol": "Requested",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organization",
"actionsCol": "",
"approveBtn": "Approve",
"rejectBtn": "Reject",
"confirmRejectBtn": "Confirm rejection",
"working": "Working…",
"cancel": "Cancel",
"reasonLabel": "Reason (shown to the customer)",
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
"reasonRequired": "A reason is required to reject."
},
"customerBilling": {
"title": "Billing",
"subtitle": "Your current period and invoice history. Issued invoices are available as PDF downloads.",
"backToBilling": "Back to billing",
"currentPeriodHeading": "Current period",
"historyHeading": "Invoice history",
"computing": "Computing current period total…",
"currentPeriodError": "Could not load the current period total. Please try again later.",
"noBillingConfig": "Billing details haven't been configured yet. Once your organization's billing address is on file, this widget will show the running total.",
"accruedSoFar": "Accrued this month",
"estimatedTotal": "Estimated total",
"currentInvoiceIssued": "Current month already invoiced",
"refresh": "refresh",
"breakdownToggle": "Show breakdown ({count} line items)",
"draftNote": "Live estimate. The final invoice may differ slightly due to end-of-month rounding, late-arriving usage data, or manual adjustments.",
"emptyHistory": "No invoices issued yet. Once your first month closes, you'll see it here.",
"numberCol": "Number",
"periodCol": "Period",
"dueCol": "Due",
"totalCol": "Total",
"statusCol": "Status",
"descriptionCol": "Description",
"qtyCol": "Qty",
"unitCol": "Unit",
"amountCol": "Amount",
"billedToLabel": "Billed to",
"issuedAtLabel": "Issued",
"dueAtLabel": "Due by",
"paidAtLabel": "Paid on",
"subtotalLabel": "Subtotal",
"vatLabel": "VAT ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Download PDF",
"status": {
"draft": "Draft",
"open": "Open",
"paid": "Paid",
"overdue": "Overdue",
"void": "Void",
"uncollectible": "Uncollectible"
},
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you. The status will update once Stripe confirms (a few seconds).",
"paymentCancelled": "Payment was cancelled. The invoice is still open; you can try again whenever."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Équipe",
"settings": "Paramètres",
"optional": "facultatif",
"support": "Support"
"support": "Support",
"billing": "Facturation"
},
"login": {
"title": "Portail PieCed",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'intégration de Google Workspace utilise OAuth et nécessite actuellement une intégration manuelle. Veuillez ouvrir un ticket de support pour démarrer la configuration — nous échangerons hors ligne les identifiants client et un jeton de rafraîchissement, puis activerons ce package sur votre tenant.",
"instructions": "Google Workspace utilise OAuth. Créez un client OAuth dans votre projet Google Cloud, autorisez-le avec les scopes nécessaires (Gmail, Agenda, Drive, etc.), puis collez les identifiants ci-dessous. La soumission les stocke en sécurité et place votre activation dans la file de revue administrative — après approbation, l'intégration s'active automatiquement.",
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
},
"mail": {
@@ -311,7 +312,13 @@
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
}
},
"manualReviewPending": "Revue manuelle en attente",
"withdraw": "Retirer",
"activationRejected": "Refusée",
"tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés",
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
},
"admin": {
"title": "Admin plateforme",
@@ -385,7 +392,8 @@
"resumeRequestBadge": "Reprise",
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →"
"billingTool": "Facturation →",
"skillsQueueTool": "File d'activation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -586,17 +594,17 @@
"save": "Enregistrer",
"saving": "Enregistrement…",
"savedOk": "Enregistré",
"skillPricingTitle": "Tarifs des skills",
"skillPricingDesc": "Prix journalier par skill. Un skill activé à tout moment au cours d'une journée UTC compte comme un jour facturable.",
"skillCol": "Skill",
"skillPricingTitle": "Tarification des paquets",
"skillPricingDesc": "Tarif journalier et frais de configuration uniques pour chaque paquet — core, canal ou skill. La tarification s'applique à chaque tenant activant le paquet.",
"skillCol": "Paquet",
"dailyPriceCol": "Prix/jour",
"actionsCol": "",
"remove": "Retirer",
"noSkillsPriced": "Aucun skill n'a encore de prix.",
"addSkillLabel": "Ajouter un skill",
"noSkillsPriced": "Aucun paquet tarifé.",
"addSkillLabel": "Ajouter un paquet",
"dailyPriceLabel": "Prix/jour",
"add": "Ajouter",
"confirmDeleteSkillPrice": "Retirer le prix pour {skill}?",
"confirmDeleteSkillPrice": "Supprimer la tarification de {skill} ? Les périodes déjà facturées ne sont pas affectées.",
"clickToEdit": "Cliquer pour modifier",
"generateFormTitle": "Générer une facture",
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
@@ -653,6 +661,86 @@
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
"paidOnLabel": "Payée le",
"lineItemsTitle": "Lignes",
"billToSnapshotTitle": "Destinataire"
"billToSnapshotTitle": "Destinataire",
"setupFeeCol": "Frais de configuration",
"skillSetupFeeLabel": "Frais de configuration"
},
"skillCostDialog": {
"title": "Confirmer le coût d'activation",
"intro": "L'activation de {skill} entraînera les frais suivants :",
"setupFeeLabel": "Frais de configuration",
"setupFeeNote": "Unique, facturé uniquement à la première activation",
"monthlyPriceLabel": "Prix mensuel",
"monthlyPriceNote": "CHF {daily}/jour actif ; mois partiels prorata journalier",
"monthUnit": "mois",
"disclaimer": "Ces frais figureront sur votre prochaine facture mensuelle. En confirmant, vous acceptez de les engager.",
"cancel": "Annuler",
"confirm": "Confirmer & activer",
"confirming": "Activation…"
},
"adminSkills": {
"title": "File d'activation",
"subtitle": "Demandes clients d'activation de paquets nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"backToAdmin": "Retour à l'administration",
"emptyQueue": "Aucune demande d'activation en attente.",
"requestedAtCol": "Demandée le",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organisation",
"actionsCol": "",
"approveBtn": "Approuver",
"rejectBtn": "Refuser",
"confirmRejectBtn": "Confirmer le refus",
"working": "En cours…",
"cancel": "Annuler",
"reasonLabel": "Motif (visible par le client)",
"reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.",
"reasonRequired": "Un motif est requis pour refuser."
},
"customerBilling": {
"title": "Facturation",
"subtitle": "Période en cours et historique des factures. Les factures émises sont disponibles en téléchargement PDF.",
"backToBilling": "Retour à la facturation",
"currentPeriodHeading": "Période en cours",
"historyHeading": "Historique des factures",
"computing": "Calcul du total de la période en cours…",
"currentPeriodError": "Impossible de charger le total de la période en cours. Veuillez réessayer plus tard.",
"noBillingConfig": "Les informations de facturation ne sont pas encore configurées. Une fois l'adresse de facturation de votre organisation enregistrée, le total en cours apparaîtra ici.",
"accruedSoFar": "Cumulé ce mois",
"estimatedTotal": "Total estimé",
"currentInvoiceIssued": "Mois en cours déjà facturé",
"refresh": "actualiser",
"breakdownToggle": "Afficher le détail ({count} lignes)",
"draftNote": "Estimation en direct. La facture finale peut légèrement varier en raison d'arrondis de fin de mois, de données d'utilisation tardives ou d'ajustements manuels.",
"emptyHistory": "Aucune facture émise pour le moment. Après la clôture de votre premier mois, elles apparaîtront ici.",
"numberCol": "Numéro",
"periodCol": "Période",
"dueCol": "Échéance",
"totalCol": "Total",
"statusCol": "Statut",
"descriptionCol": "Description",
"qtyCol": "Qté",
"unitCol": "Prix unitaire",
"amountCol": "Montant",
"billedToLabel": "Facturé à",
"issuedAtLabel": "Émise le",
"dueAtLabel": "À régler avant",
"paidAtLabel": "Payée le",
"subtotalLabel": "Sous-total",
"vatLabel": "TVA ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Télécharger le PDF",
"status": {
"draft": "Brouillon",
"open": "Ouverte",
"paid": "Payée",
"overdue": "En retard",
"void": "Annulée",
"uncollectible": "Irrécouvrable"
},
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci. Le statut sera mis à jour dès la confirmation de Stripe (quelques secondes).",
"paymentCancelled": "Le paiement a été annulé. La facture reste ouverte ; vous pouvez réessayer à tout moment."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Impostazioni",
"optional": "facoltativo",
"support": "Supporto"
"support": "Supporto",
"billing": "Fatturazione"
},
"login": {
"title": "Portale PieCed",
@@ -287,7 +288,7 @@
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Token di refresh Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'integrazione con Google Workspace utilizza OAuth e richiede attualmente un onboarding manuale. Apri un ticket di supporto per avviare la configurazione — scambieremo le credenziali del client e un token di refresh offline, quindi abiliteremo questo pacchetto sul tuo tenant.",
"instructions": "Google Workspace utilizza OAuth. Crea un client OAuth nel tuo progetto Google Cloud, autorizzalo con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attiva automaticamente.",
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
},
"mail": {
@@ -311,7 +312,13 @@
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
}
},
"manualReviewPending": "Revisione manuale in attesa",
"withdraw": "Ritira",
"activationRejected": "Rifiutata",
"tryAgain": "Riprova",
"credentialsSaved": "credenziali salvate",
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle."
},
"admin": {
"title": "Admin piattaforma",
@@ -385,7 +392,8 @@
"resumeRequestBadge": "Ripresa",
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →"
"billingTool": "Fatturazione →",
"skillsQueueTool": "Coda di attivazione"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -586,17 +594,17 @@
"save": "Salva",
"saving": "Salvataggio…",
"savedOk": "Salvato",
"skillPricingTitle": "Prezzi skill",
"skillPricingDesc": "Prezzo giornaliero per skill. Una skill attiva in qualsiasi momento di un giorno UTC conta come un giorno fatturabile.",
"skillCol": "Skill",
"skillPricingTitle": "Prezzi dei pacchetti",
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attiva il pacchetto.",
"skillCol": "Pacchetto",
"dailyPriceCol": "Prezzo/giorno",
"actionsCol": "",
"remove": "Rimuovi",
"noSkillsPriced": "Nessuna skill ha ancora un prezzo.",
"addSkillLabel": "Aggiungi skill",
"noSkillsPriced": "Nessun pacchetto con prezzo.",
"addSkillLabel": "Aggiungi pacchetto",
"dailyPriceLabel": "Prezzo/giorno",
"add": "Aggiungi",
"confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?",
"confirmDeleteSkillPrice": "Rimuovere la tariffazione per {skill}? I periodi già fatturati non sono influenzati.",
"clickToEdit": "Clicca per modificare",
"generateFormTitle": "Genera fattura",
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
@@ -653,6 +661,86 @@
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
"paidOnLabel": "Pagata il",
"lineItemsTitle": "Righe",
"billToSnapshotTitle": "Destinatario"
"billToSnapshotTitle": "Destinatario",
"setupFeeCol": "Spese di attivazione",
"skillSetupFeeLabel": "Spese di attivazione"
},
"skillCostDialog": {
"title": "Conferma costi di attivazione",
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
"setupFeeLabel": "Spese di attivazione",
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
"monthlyPriceLabel": "Prezzo mensile",
"monthlyPriceNote": "CHF {daily}/giorno attivo; mesi parziali calcolati al giorno",
"monthUnit": "mese",
"disclaimer": "Questi costi appariranno sulla prossima fattura mensile. Confermando accetti di sostenerli.",
"cancel": "Annulla",
"confirm": "Conferma & attiva",
"confirming": "Attivazione…"
},
"adminSkills": {
"title": "Coda di attivazione",
"subtitle": "Richieste dei clienti per attivare pacchetti che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
"backToAdmin": "Torna ad amministrazione",
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
"requestedAtCol": "Richiesta",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organizzazione",
"actionsCol": "",
"approveBtn": "Approva",
"rejectBtn": "Rifiuta",
"confirmRejectBtn": "Conferma rifiuto",
"working": "In corso…",
"cancel": "Annulla",
"reasonLabel": "Motivo (mostrato al cliente)",
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
"reasonRequired": "Un motivo è necessario per rifiutare."
},
"customerBilling": {
"title": "Fatturazione",
"subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.",
"backToBilling": "Torna alla fatturazione",
"currentPeriodHeading": "Periodo corrente",
"historyHeading": "Cronologia fatture",
"computing": "Calcolo del totale del periodo corrente…",
"currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.",
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.",
"accruedSoFar": "Accumulato questo mese",
"estimatedTotal": "Totale stimato",
"currentInvoiceIssued": "Mese corrente già fatturato",
"refresh": "aggiorna",
"breakdownToggle": "Mostra dettaglio ({count} voci)",
"draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.",
"emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.",
"numberCol": "Numero",
"periodCol": "Periodo",
"dueCol": "Scadenza",
"totalCol": "Totale",
"statusCol": "Stato",
"descriptionCol": "Descrizione",
"qtyCol": "Qtà",
"unitCol": "Prezzo unitario",
"amountCol": "Importo",
"billedToLabel": "Fatturato a",
"issuedAtLabel": "Emessa il",
"dueAtLabel": "Scadenza",
"paidAtLabel": "Pagata il",
"subtotalLabel": "Subtotale",
"vatLabel": "IVA ({rate}%)",
"totalLabel": "Totale",
"downloadPdf": "Scarica PDF",
"status": {
"draft": "Bozza",
"open": "Aperta",
"paid": "Pagata",
"overdue": "In ritardo",
"void": "Annullata",
"uncollectible": "Inesigibile"
},
"payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…",
"paymentReceived": "Pagamento ricevuto — grazie. Lo stato si aggiornerà alla conferma di Stripe (pochi secondi).",
"paymentCancelled": "Il pagamento è stato annullato. La fattura rimane aperta; puoi riprovare in qualsiasi momento."
}
}

View File

@@ -449,6 +449,13 @@ export interface PlatformPricing {
export interface SkillPricing {
skillId: string;
dailyPriceChf: number;
/**
* One-time setup fee charged the first time this skill appears
* on an invoice for a given tenant. Detection mirrors the
* tenant-level setup fee: a `skill_setup` line is emitted only
* when no prior invoice line exists for (tenant, skill).
*/
setupFeeChf: number;
createdAt: string;
updatedAt: string;
}
@@ -541,6 +548,7 @@ export type InvoiceLineKind =
| "ai_usage"
| "threema_messages"
| "skill_usage"
| "skill_setup"
| "adjustment";
/**
@@ -647,3 +655,40 @@ export interface InvoiceDraft {
*/
warnings: string[];
}
// ---------------------------------------------------------------------------
// Skill activation requests — manual provisioning queue
// ---------------------------------------------------------------------------
export type SkillActivationStatus =
| "pending"
| "approved"
| "rejected"
| "withdrawn";
/**
* A customer-initiated request to enable a flagged-as-manual-setup
* skill on a specific tenant. Lifecycle:
*
* pending → approved (admin clicks Approve; skill added to spec)
* pending → rejected (admin clicks Reject with reason)
* pending → withdrawn (owner cancels their own request)
*
* Approved and withdrawn rows are kept for audit but don't block
* new pending requests on the same (tenant, skill). The unique
* partial index allows at most one row in 'pending' status per
* (tenant_name, skill_id).
*/
export interface SkillActivationRequest {
id: string;
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
skillId: string;
status: SkillActivationStatus;
requestedAt: string;
reviewedAt: string | null;
reviewedBy: string | null;
rejectionReason: string | null;
adminNotes: string | null;
}