Compare commits

..

10 Commits

Author SHA1 Message Date
a6ed74b1be Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-28 23:27:32 +02:00
1741574eb2 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m54s
2026-05-28 23:03:46 +02:00
d78f9f2696 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m44s
2026-05-28 21:49:59 +02:00
3fe3597553 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
2026-05-28 21:29:15 +02:00
9243beddd3 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-27 22:20:13 +02:00
a6c3c42ec9 Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 1m2s
2026-05-27 22:12:25 +02:00
ee6bb89fb6 Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 42s
2026-05-27 22:06:32 +02:00
ad4f614130 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-27 20:45:25 +02:00
8e7691d38a Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 43s
2026-05-27 20:41:17 +02:00
9939f75c03 Phase7c: Fix Cronjob
All checks were successful
Build and Push / build (push) Successful in 1m44s
2026-05-26 23:43:04 +02:00
30 changed files with 3161 additions and 220 deletions

View File

@@ -0,0 +1,83 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
import { listTenants } from "@/lib/k8s";
import { BackLink } from "@/components/ui/back-link";
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
/**
* /admin/billing/orgs — list of orgs with their payment mode
* settings.
*
* Phase 9b-2. The customer's /settings/billing only exposes the
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
* customer must contact support to request it, admin flips the
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
* pause-switch for support situations.
*
* The page is intentionally minimal: org name, country, current
* mode, has-saved-card indicator, and toggles. Detail-level work
* (open balances, invoice list) is on the existing pages
* (/admin/billing, /admin/billing/invoices).
*/
export default async function AdminOrgsPaymentModePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
// labels are the source of truth for org membership. We dedupe by
// org id since one org can own many tenants.
const tenants = await listTenants().catch(() => []);
const orgIds = new Set<string>();
for (const tnt of tenants) {
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (oid) orgIds.add(oid);
}
const orgs = await Promise.all(
Array.from(orgIds).map(async (oid) => {
const [billing, cfg] = await Promise.all([
getOrgBilling(oid).catch(() => null),
getOrgBillingConfig(oid),
]);
return {
zitadelOrgId: oid,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
cardLabel:
cfg.stripePmBrand && cfg.stripePmLast4
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
: null,
payByInvoice: !!cfg.payByInvoice,
autoChargeEnabled: cfg.autoChargeEnabled !== false,
};
})
);
// Sort: orgs with billing first (most actionable), then by name.
orgs.sort((a, b) => {
if (!!a.companyName !== !!b.companyName) {
return a.companyName ? -1 : 1;
}
return (a.companyName ?? a.zitadelOrgId).localeCompare(
b.companyName ?? b.zitadelOrgId
);
});
return (
<main className="max-w-6xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("orgsPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("orgsPageSubtitle")}
</p>
</div>
<OrgPaymentModeList orgs={orgs} />
</main>
);
}

View File

@@ -66,7 +66,7 @@ export default async function AdminBillingPage() {
</div> </div>
{/* Sub-tool cards */} {/* Sub-tool cards */}
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
<Link href="/admin/billing/pricing"> <Link href="/admin/billing/pricing">
<Card interactive> <Card interactive>
<div className="font-semibold mb-1">{t("pricingTitle")}</div> <div className="font-semibold mb-1">{t("pricingTitle")}</div>
@@ -85,6 +85,12 @@ export default async function AdminBillingPage() {
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div> <div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
</Card> </Card>
</Link> </Link>
<Link href="/admin/billing/orgs">
<Card interactive>
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
</Card>
</Link>
</div> </div>
{/* Orgs with open balance */} {/* Orgs with open balance */}

View File

@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link"; import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db"; import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org"; import { personalAccountAtCapacity } from "@/lib/personal-org";
/** /**
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
} }
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const orgBilling = await getOrgBilling(user.orgId); const [orgBilling, pricing] = await Promise.all([
getOrgBilling(user.orgId),
getPlatformPricing(),
]);
const hasOrgBilling = orgBilling !== null; const hasOrgBilling = orgBilling !== null;
return ( return (
@@ -77,6 +80,7 @@ export default async function NewInstancePage() {
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling} existingOrgBilling={orgBilling}
setupFeeChf={pricing.tenantSetupFeeChf}
/> />
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import {
listActiveTenantRequestsByOrgId, listActiveTenantRequestsByOrgId,
syncProvisioningStatuses, syncProvisioningStatuses,
getOrgBilling, getOrgBilling,
getPlatformPricing,
} from "@/lib/db"; } from "@/lib/db";
import { import {
listVisibleTenants, listVisibleTenants,
@@ -192,6 +193,7 @@ export default async function DashboardPage() {
// component. // component.
const orgBilling = await getOrgBilling(user.orgId); const orgBilling = await getOrgBilling(user.orgId);
const hasOrgBilling = orgBilling !== null; const hasOrgBilling = orgBilling !== null;
const platformPricing = await getPlatformPricing();
// Pending requests that don't yet have a tenant CR. Once the CR // Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate // exists, the tenant card carries the live phase, so a separate
@@ -318,6 +320,7 @@ export default async function DashboardPage() {
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling} existingOrgBilling={orgBilling}
setupFeeChf={platformPricing.tenantSetupFeeChf}
/> />
</div> </div>
</div> </div>

View File

@@ -1,8 +1,9 @@
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db"; import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-form"; import { BillingSettingsForm } from "@/components/settings/billing-form";
import { SavedCardSection } from "@/components/settings/saved-card-section";
/** /**
* /settings/billing — customer-side billing details management. * /settings/billing — customer-side billing details management.
@@ -17,6 +18,11 @@ import { BillingSettingsForm } from "@/components/settings/billing-form";
* the current values, editable. Save creates or updates via the * the current values, editable. Save creates or updates via the
* shared upsert path; the row's existence drives whether the * shared upsert path; the row's existence drives whether the
* monthly issuance cron will pick this org up. * monthly issuance cron will pick this org up.
*
* Phase 9: also renders the saved-card section (Set up auto-pay /
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
* auto-pay / Remove card) when billing info is on file, plus a
* footer note explaining that bank transfer is available on request.
*/ */
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -25,7 +31,10 @@ export default async function BillingSettingsPage() {
if (!user.roles.includes("owner")) notFound(); if (!user.roles.includes("owner")) notFound();
const t = await getTranslations("settingsBilling"); const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId); const [existing, config] = await Promise.all([
getOrgBilling(user.orgId),
getOrgBillingConfig(user.orgId),
]);
return ( return (
<main className="max-w-3xl mx-auto px-6 py-8"> <main className="max-w-3xl mx-auto px-6 py-8">
@@ -43,6 +52,20 @@ export default async function BillingSettingsPage() {
isPersonal={user.isPersonal} isPersonal={user.isPersonal}
/> />
</div> </div>
{/* Phase 9: saved-card section. Only shown once billing info
exists — without an address Stripe can't create the
customer object, so the "Set up auto-pay" button would
fail anyway. We give a clear hint up there if the form
is empty (no need to surface the card UI). */}
{existing && (
<div className="animate-in animate-in-delay-2 mt-8">
<SavedCardSection
config={config}
isPayByInvoice={!!config?.payByInvoice}
isPersonal={user.isPersonal}
/>
</div>
)}
</main> </main>
); );
} }

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import {
getOrgBillingConfig,
setAutoChargeEnabled,
updateOrgBillingConfig,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/orgs/[orgId]/payment-mode
*
* Phase 9b-2. Admin-only override of an org's billing mode:
* - payByInvoice (boolean) — flip the customer's account to
* bank-transfer billing. Auto-charge is skipped entirely for
* these orgs; they receive the regular issued-invoice email
* and pay manually. Switching ON also implicitly stops
* attempting card charges even if a saved card exists.
* - autoChargeEnabled (boolean) — pause auto-charge without
* committing to pay-by-invoice. Useful during disputes or
* billing investigations.
*
* Either flag may be omitted; the endpoint only writes what's
* provided. Returns the updated config.
*/
const bodySchema = z.object({
payByInvoice: z.boolean().optional(),
autoChargeEnabled: z.boolean().optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ orgId: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { orgId } = 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 { payByInvoice, autoChargeEnabled } = parsed.data;
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
return NextResponse.json(
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
{ status: 400 }
);
}
try {
if (payByInvoice !== undefined) {
await updateOrgBillingConfig(orgId, { payByInvoice });
}
if (autoChargeEnabled !== undefined) {
await setAutoChargeEnabled(orgId, autoChargeEnabled);
}
const cfg = await getOrgBillingConfig(orgId);
return NextResponse.json({ config: cfg });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update payment mode") },
{ status: 500 }
);
}
}

View File

@@ -4,14 +4,12 @@ import {
getTenantRequestById, getTenantRequestById,
updateTenantRequestStatus, updateTenantRequestStatus,
clearEncryptedSecrets, clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} from "@/lib/db"; } from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s"; import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email"; import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto"; import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao"; import { writePackageSecrets } from "@/lib/openbao";
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
import { import {
getDefaultSoulMd, getDefaultSoulMd,
getDefaultAgentsMd, getDefaultAgentsMd,
@@ -88,23 +86,6 @@ export async function POST(
} }
try { try {
await patchTenantSpec(tenantRequest.tenantName, { suspend: false }); await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
// Billing — Phase 1: record the resume so monthly proration
// counts the suspended segment correctly. Best-effort; if
// logging fails, the approval still succeeds.
try {
await recordSuspensionEvent(
tenantRequest.tenantName,
tenantRequest.zitadelOrgId,
"resumed"
);
} catch (e) {
console.error(
"billing: failed to record resumed suspension event:",
e
);
}
// Clear the annotation that pauses the operator's 60-day TTL. // Clear the annotation that pauses the operator's 60-day TTL.
// Best-effort — annotation cleanup is also done by the operator // Best-effort — annotation cleanup is also done by the operator
// when it sees suspend=false on the next reconcile (it clears // when it sees suspend=false on the next reconcile (it clears
@@ -197,6 +178,29 @@ export async function POST(
? tenantRequest.contactName || "Assistant" ? tenantRequest.contactName || "Assistant"
: tenantRequest.companyName; : tenantRequest.companyName;
// Phase 9b: split the customer's initial channel-user ids into
// (a) ids the operator needs in spec.channelUsers (telegram,
// discord, …) — passed straight into createTenant
// (b) Threema ids that ALSO need a relay route registered so
// inbound messages reach this tenant. Threema is in (a)
// AND (b): spec.channelUsers tells the operator the id is
// authorized; the relay's route maps inbound traffic from
// that id to this tenant.
const initialChannelUsers = tenantRequest.channelUsers ?? {};
// Strip channels the customer didn't actually enable (defensive
// — the wizard already filters this, but the row could carry
// stale data if the customer edited their request post-submit).
const filteredChannelUsers: Record<string, string[]> = {};
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
if (!packages.includes(channel)) continue;
const cleaned = (ids ?? [])
.map((s) => (s ?? "").trim())
.filter((s) => s.length > 0);
if (cleaned.length > 0) {
filteredChannelUsers[channel] = cleaned;
}
}
await createTenant( await createTenant(
tenantName, tenantName,
{ {
@@ -204,6 +208,9 @@ export async function POST(
agentName: tenantRequest.agentName, agentName: tenantRequest.agentName,
packages, packages,
workspaceFiles, workspaceFiles,
...(Object.keys(filteredChannelUsers).length > 0
? { channelUsers: filteredChannelUsers }
: {}),
}, },
{ {
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId, "pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
@@ -219,33 +226,33 @@ export async function POST(
} }
); );
// Billing — Phase 1: record the tenant's creation and initial // Threema: register relay routes for each id the customer
// package state. Anchored at "now" rather than the CR's // entered. Best-effort — a route failure doesn't unwind the
// creationTimestamp because we don't get the timestamp back from // tenant creation (admin can retry from the tenant page later).
// createTenant — the few-millisecond skew vs the CR's actual // The Threema package itself isn't enabled on the tenant until
// creationTimestamp is irrelevant for monthly billing. // the customer toggles it from the tenant detail page (which
// // also mints the per-tenant token); the routes here pre-warm
// Best-effort: tracking failures must never block provisioning. // the relay so the first toggle works without re-typing the id.
// The backfill helper can repair any gaps later if needed. if (
const billingAnchor = new Date(); packages.includes("threema") &&
try { filteredChannelUsers.threema &&
await recordTenantCreated( filteredChannelUsers.threema.length > 0
tenantName, ) {
tenantRequest.zitadelOrgId, for (const tid of filteredChannelUsers.threema) {
billingAnchor try {
); const res = await createRelayRoute(tenantName, tid);
await recordSkillEvents( if (!res.ok) {
tenantName, console.warn(
tenantRequest.zitadelOrgId, `[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
packages, );
[], }
billingAnchor } catch (e) {
); console.error(
} catch (e) { `[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
console.error( e
"billing: failed to record tenant creation / initial skill events:", );
e }
); }
} }
// Step 5: Update request status — clear admin notes on re-approval // Step 5: Update request status — clear admin notes on re-approval

View File

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

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
/**
* POST /api/billing/auto-charge — RETIRED.
*
* Auto-pay is no longer a customer-toggleable setting. A saved
* card on file is the consent to auto-bill; customers manage their
* card via update/remove on /settings/billing, nothing else. The
* auto_charge_enabled flag is now an admin-only pause used during
* disputes, set from /admin/billing/orgs.
*
* This route is kept as an explicit 410 (Gone) so any stale client
* that still POSTs here fails loudly rather than silently toggling
* a flag the customer shouldn't control. The old behaviour lived
* here through Phase 9b-2.
*/
export async function POST() {
return NextResponse.json(
{
error:
"Auto-pay can no longer be disabled. A saved card is required for service. " +
"Contact support if you need to switch to bank-transfer billing.",
code: "auto_pay_not_toggleable",
},
{ status: 410 }
);
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
import { detachPaymentMethod } from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/billing/saved-card
*
* Phase 9. Remove the saved card for the caller's org. Detaches
* the PaymentMethod in Stripe (so it can't be charged again) and
* clears the four display columns + the pm_id reference locally.
*
* Idempotent: calling on an org with no saved card returns 200
* (the desired end-state is already reached).
*
* Auth: any signed-in member of the org. Same reasoning as the
* setup endpoint — card removal is a customer-visible action; it
* doesn't leak anything, and a non-owner needing to remove a
* stolen-card-on-file shouldn't be blocked by role gating.
*/
export async function DELETE() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const cfg = await getOrgBillingConfig(user.orgId);
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
// Already empty — no-op, return success.
return NextResponse.json({ removed: false });
}
// Stripe detach first. If it fails for a real reason (network,
// 500 from Stripe), we don't clear the DB — admin can retry.
// 404 is treated as success by detachPaymentMethod (PM already
// gone), so we proceed to clear the DB regardless.
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
await clearSavedPaymentMethod(user.orgId);
return NextResponse.json({ removed: true });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to remove card") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import {
createSetupCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/setup-card
*
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
* flow. Creates a Checkout session in setup mode and returns its
* URL — the caller redirects the browser. On completion, the
* webhook handler saves the resulting PaymentMethod's display
* fields against this org's billing config.
*
* Auth: any signed-in member of the org. We don't owner-gate this
* because non-owners might legitimately need to update payment
* (e.g., for a team they administer). The actual card data is
* collected by Stripe, not us — there's nothing to leak from
* misuse here.
*
* Requires an existing billing snapshot (org_billing row). If
* absent, returns 400 — the customer hasn't set their billing
* address yet, and Stripe needs the address for the customer
* object.
*/
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing address required before saving a card." },
{ status: 400 }
);
}
try {
// Ensure the Stripe customer exists. Idempotent — if we
// already created one for this org (e.g. from a prior
// "Pay by Card" Checkout), it's reused.
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,
},
});
// Base URL for redirect targets — must be the public-facing
// origin since Stripe redirects the browser back. Behind an
// ingress (Cedric's setup) request.url is the internal pod
// address ("0.0.0.0:3000" / cluster.svc), useless for the
// browser. Same env-var pattern as the invoice pay endpoint.
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const session = await createSetupCheckoutSession({
customerId,
baseUrl,
});
return NextResponse.json({ url: session.url });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to start card setup") },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
getInvoiceById,
getTenantRequestById, getTenantRequestById,
updateTenantRequestStatus, updateTenantRequestStatus,
updateTenantRequestEditableFields, updateTenantRequestEditableFields,
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
import { setTenantAnnotation } from "@/lib/k8s"; import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation"; import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors"; import { safeError } from "@/lib/errors";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser, TenantRequest } from "@/types";
/** /**
* Customer-side controls for a single tenant_request row. * Customer-side controls for a single tenant_request row.
@@ -29,7 +32,7 @@ async function loadAuthorized(
id: string id: string
): Promise< ): Promise<
| { error: NextResponse } | { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; } | { req: TenantRequest; user: SessionUser }
> { > {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) { if (!user) {
@@ -55,7 +58,7 @@ async function loadAuthorized(
error: NextResponse.json({ error: "Not found" }, { status: 404 }), error: NextResponse.json({ error: "Not found" }, { status: 404 }),
}; };
} }
return { req: tr }; return { req: tr, user };
} }
/** /**
@@ -93,6 +96,50 @@ export async function DELETE(
try { try {
await updateTenantRequestStatus(id, "cancelled"); await updateTenantRequestStatus(id, "cancelled");
// Phase 9b: a 'pending' provision request has already had its
// setup fee charged (the order-time Checkout completed before
// the webhook flipped it to 'pending'). Cancelling it must
// refund that payment, exactly as an admin rejection does.
// Resume requests never carry a setup_invoice_id, so this only
// fires for provision orders. Best-effort: a refund failure is
// logged + surfaced but doesn't block the cancellation (admin
// can refund manually from the invoice page).
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
attempted: false,
succeeded: false,
};
if (tr.requestType === "provision" && tr.setupInvoiceId) {
refund.attempted = true;
try {
const inv = await getInvoiceById(tr.setupInvoiceId);
if (!inv) {
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
}
const remaining =
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
if (remaining <= 0) {
refund.succeeded = true; // nothing left to refund
} else {
await refundInvoice({
invoiceId: tr.setupInvoiceId,
amountChf: remaining,
reason: "Order cancelled by customer",
refundedBy: loaded.user!.id,
});
refund.succeeded = true;
}
} catch (e: any) {
refund.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
e
);
}
}
// Customer cancels their own pending resume request: clear the // Customer cancels their own pending resume request: clear the
// operator-side annotation so the 60-day TTL resumes counting. // operator-side annotation so the 60-day TTL resumes counting.
// Best-effort — the operator handles missing annotation gracefully. // Best-effort — the operator handles missing annotation gracefully.
@@ -111,7 +158,7 @@ export async function DELETE(
} }
} }
return NextResponse.json({ message: "Request cancelled.", id }); return NextResponse.json({ message: "Request cancelled.", id, refund });
} catch (e: any) { } catch (e: any) {
console.error("Failed to cancel request:", e); console.error("Failed to cancel request:", e);
return NextResponse.json( return NextResponse.json(

View File

@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
createTenantRequest, createTenantRequest,
createTenantRequestPendingPayment,
deletePendingPaymentRequest,
getTenantRequestById, getTenantRequestById,
listTenantRequestsByOrgId, listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId, listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg, getMostRecentApprovedRequestForOrg,
getOrgBilling, getOrgBilling,
getPlatformPricing,
upsertOrgBilling, upsertOrgBilling,
} from "@/lib/db"; } from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s"; import { getTenant, listTenants } from "@/lib/k8s";
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto"; import { encryptSecrets } from "@/lib/crypto";
import { isPersonalOrgName } from "@/lib/personal-org"; import { isPersonalOrgName } from "@/lib/personal-org";
import { onboardingSchema, billingAddressSchema } from "@/lib/validation"; import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types"; import {
createSetupFeeCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
import { deriveTenantName } from "@/lib/tenant-naming";
import type {
InvoiceBillingSnapshot,
OnboardingInput,
PiecedTenant,
TenantRequest,
} from "@/types";
import { z } from "zod"; import { z } from "zod";
/** /**
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
const input: OnboardingInput & { const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>; packageSecrets?: Record<string, Record<string, string>>;
channelUsers?: Record<string, string[]>;
} = parsed.data; } = parsed.data;
// Look up an existing approved request for this org to inherit // Look up an existing approved request for this org to inherit
@@ -402,7 +417,64 @@ export async function POST(request: Request) {
); );
} }
const tenantRequest = await createTenantRequest({ // Look up the setup fee. If it's 0 we skip the Checkout flow
// entirely and create a normal pending request (same as the
// pre-Phase-9b behaviour).
const platformPricing = await getPlatformPricing();
const setupFeeChf = platformPricing.tenantSetupFeeChf;
// ZERO-FEE PATH ---------------------------------------------------
// No payment to collect. Create the request directly in 'pending'
// status (same as the pre-Phase-9b flow) and notify admin. The
// wizard treats this response identically to its previous
// success path.
if (setupFeeChf <= 0) {
const tenantRequest = await createTenantRequest({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
companyName,
instanceName: input.instanceName,
contactName,
contactEmail,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress,
billingNotes,
encryptedSecrets,
isPersonal,
channelUsers: input.channelUsers ?? {},
});
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
);
} catch (e) {
console.error("Failed to send admin notification:", e);
}
const allRequests = await listTenantRequestsByOrgId(user.orgId);
return NextResponse.json(
{
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 }
);
}
// PAID-FEE PATH ---------------------------------------------------
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
// Checkout sessions don't block retries). Build the setup-fee
// invoice, then start a Checkout session. The wizard follows the
// returned URL; on completion the webhook flips the row to
// 'pending' and admin sees it in their queue.
const tenantRequest = await createTenantRequestPendingPayment({
zitadelOrgId: user.orgId, zitadelOrgId: user.orgId,
zitadelUserId: user.id, zitadelUserId: user.id,
companyName, companyName,
@@ -417,32 +489,140 @@ export async function POST(request: Request) {
billingNotes, billingNotes,
encryptedSecrets, encryptedSecrets,
isPersonal, isPersonal,
channelUsers: input.channelUsers ?? {},
}); });
// Notify admin about the new request. For follow-up instances, include // Derive the future tenant_name — needed on the invoice line so
// the instance name in the notification so the admin sees what's // tenantHasSetupFeeBilled() in the monthly cron dedup finds the
// being requested without opening the panel. // already-paid setup fee once the K8s tenant exists. The name is
try { // request-id-suffix-derived, so abandoned Checkout retries each
await sendAdminNotificationEmail( // get unique names.
tenantRequest.contactEmail, const derivedTenantName = deriveTenantName(
tenantRequest.contactName, isPersonal ? "personal" : "company",
tenantRequest.instanceName companyName,
? `${tenantRequest.companyName} (${tenantRequest.instanceName})` tenantRequest.id
: tenantRequest.companyName );
// Re-fetch orgBilling here: the variable at the top of POST was
// captured BEFORE the upsertOrgBilling call upstream (which fires
// when the wizard collected the address on first onboarding). For
// a brand-new user that initial fetch returned null; only by
// re-fetching now do we get the row we just wrote. Existing
// customers get the same orgBilling back either way.
const billingForOrder = await getOrgBilling(user.orgId);
if (!billingForOrder) {
console.error(
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
); );
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Billing record missing. Please re-save your billing details." },
{ status: 500 }
);
}
const billingSnapshot: InvoiceBillingSnapshot = {
companyName: billingForOrder.companyName,
contactName: billingForOrder.contactName ?? null,
streetAddress: billingForOrder.streetAddress,
postalCode: billingForOrder.postalCode,
city: billingForOrder.city,
country: billingForOrder.country,
vatNumber: billingForOrder.vatNumber ?? null,
billingEmail: billingForOrder.billingEmail,
notes: billingForOrder.notes ?? null,
};
// Locale for the invoice + PDF — pick from the org's country
// using the same heuristic the auto-cron uses.
const c = (billingSnapshot.country ?? "").toUpperCase();
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
? "de"
: ["FR", "BE", "LU"].includes(c)
? "fr"
: c === "IT"
? "it"
: "en";
let setupInvoice;
try {
setupInvoice = await createTenantSetupFeeInvoice({
zitadelOrgId: user.orgId,
tenantName: derivedTenantName,
billingSnapshot,
locale: invoiceLocale,
paymentMethod: "card",
});
} catch (e) { } catch (e) {
console.error("Failed to send admin notification:", e); console.error("Failed to create setup-fee invoice:", e);
// Roll back the pending_payment row so the customer can retry
// without an orphan record.
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to prepare setup-fee invoice. Please try again." },
{ status: 500 }
);
} }
// For diagnostics: how many other in-flight requests does this org // Create the Checkout session. The Stripe customer must exist
// already have? Useful for the admin queue. // before this — ensureStripeCustomerForOrg returns the existing
const allRequests = await listTenantRequestsByOrgId(user.orgId); // one (idempotent) since the saved-card setup already created it.
let checkoutUrl: string;
try {
const stripeCustomerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: billingSnapshot.companyName,
billingEmail: billingSnapshot.billingEmail,
address: {
line1: billingSnapshot.streetAddress,
postalCode: billingSnapshot.postalCode,
city: billingSnapshot.city,
country: billingSnapshot.country,
},
});
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const { url } = await createSetupFeeCheckoutSession({
invoice: setupInvoice,
customerId: stripeCustomerId,
baseUrl,
tenantRequestId: tenantRequest.id,
});
checkoutUrl = url;
} catch (e) {
console.error("Failed to create setup-fee Checkout session:", e);
// Roll back BOTH the pending_payment row and the setup invoice
// we already created. The invoice was issued in 'open' status
// but no payment will ever arrive (Checkout never started), so
// void it to keep the ledger clean — an open invoice with no
// route to payment would otherwise linger and show up in
// arrears reports. Void (not delete) preserves the audit trail
// and the void reason. Best-effort: a void failure is logged
// but doesn't change the 500 we return.
await voidInvoice({
invoiceId: setupInvoice.id,
reason: "Order abandoned before payment (Checkout could not be started)",
voidedBy: user.id,
}).catch((ve) =>
console.error(
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
ve
)
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to start payment. Please try again." },
{ status: 500 }
);
}
// Don't notify admin yet — the request is invisible to admin
// until the webhook flips it to 'pending'. Notification happens
// there.
return NextResponse.json( return NextResponse.json(
{ {
message: "Request submitted.", message: "Redirecting to payment.",
request: publicRequestShape(tenantRequest), request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length, checkoutUrl,
}, },
{ status: 201 } { status: 201 }
); );

View File

@@ -1,14 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; import {
getPaymentMethodDisplay,
getStripeClient,
getWebhookSecret,
} from "@/lib/stripe";
import { import {
getInvoiceByStripePaymentIntent, getInvoiceByStripePaymentIntent,
getInvoiceDetail,
getOrgIdByStripeCustomerId,
getTenantRequestForSetupFlow,
isStripeRefundRecorded, isStripeRefundRecorded,
linkTenantRequestSetupPayment,
markInvoicePaid, markInvoicePaid,
markStripeEventProcessed, markStripeEventProcessed,
setInvoiceStripePaymentIntent, setInvoiceStripePaymentIntent,
setSavedPaymentMethod,
tryRecordStripeEvent, tryRecordStripeEvent,
} from "@/lib/db"; } from "@/lib/db";
import { sendAdminNotificationEmail } from "@/lib/email";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
/** /**
@@ -161,6 +171,14 @@ export async function POST(request: Request) {
async function handleCheckoutCompleted( async function handleCheckoutCompleted(
session: Stripe.Checkout.Session session: Stripe.Checkout.Session
): Promise<void> { ): Promise<void> {
// Phase 9: setup-mode sessions don't pay anything — they
// authorize a card for off-session future charges. The
// PaymentMethod is attached to the customer and the session's
// setup_intent.payment_method holds the id we save.
if (session.mode === "setup") {
await handleSetupCompleted(session);
return;
}
// Defensive: paid sessions are what we want; sessions can also // Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common // complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice // for async/delayed methods like SEPA). Only flip the invoice
@@ -209,6 +227,220 @@ async function handleCheckoutCompleted(
console.log( console.log(
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).` `Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
); );
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
// order, flip the linked tenant_request row from 'pending_payment'
// to 'pending' so admin sees it in the queue. The invoice line's
// tenant_name has the derived name; we also stamp it on the
// request row so admin can act on it. linkTenantRequestSetupPayment
// is idempotent (no-op if status already advanced).
const flow = session.metadata?.flow;
const tenantRequestId = session.metadata?.tenant_request_id;
if (flow === "setup_fee" && tenantRequestId) {
try {
// The derived tenant_name lives on the invoice line we just
// marked paid. Fetch via getInvoiceDetail (existing helper).
const detail = await getInvoiceDetail(invoiceId);
const setupLine = detail?.lines.find(
(l) => l.kind === "tenant_setup" && l.tenantName
);
if (!setupLine || !setupLine.tenantName) {
console.error(
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
);
} else {
const linked = await linkTenantRequestSetupPayment({
requestId: tenantRequestId,
tenantName: setupLine.tenantName,
setupInvoiceId: invoiceId,
});
if (linked) {
console.log(
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
);
// Notify admin now that the payment cleared. Best-effort —
// a failure here doesn't undo the linkage.
try {
const req = await getTenantRequestForSetupFlow(tenantRequestId);
if (req) {
await sendAdminNotificationEmail(
req.contactEmail,
req.contactName,
req.instanceName
? `${req.companyName} (${req.instanceName})`
: req.companyName
);
}
} catch (e) {
console.error(
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
e
);
}
} else {
console.log(
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
);
}
}
} catch (e) {
console.error(
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
e
);
}
}
// Phase 9b: any payment-mode Checkout that set setup_future_usage
// attaches the resulting PaymentMethod to the customer. Read it
// back and save the display fields against the org's config —
// same behaviour as the setup-mode webhook does. This is what
// makes the setup-fee Checkout also "refresh saved card" without
// an extra step, and it's also what Phase 9b-2's manual-pay
// with setup_future_usage will rely on.
try {
if (paymentIntentId) {
const stripe = getStripeClient();
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
const pmId =
typeof pi.payment_method === "string"
? pi.payment_method
: pi.payment_method?.id;
const customerId =
typeof pi.customer === "string"
? pi.customer
: pi.customer?.id;
// setup_future_usage on the PI tells us this payment also
// saved the card. If it's not set, this was a one-off pay
// and we shouldn't overwrite anything.
if (pmId && customerId && pi.setup_future_usage === "off_session") {
const orgId = await getOrgIdByStripeCustomerId(customerId);
if (orgId) {
const display = await getPaymentMethodDisplay(pmId);
await setSavedPaymentMethod({
zitadelOrgId: orgId,
stripeCustomerId: customerId,
paymentMethodId: pmId,
brand: display.brand,
last4: display.last4,
expMonth: display.expMonth,
expYear: display.expYear,
});
// Also tell Stripe this PM is the customer's default for
// future invoice charges. Best-effort.
try {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: pmId },
});
} catch (e) {
console.warn(
`Failed to set default_payment_method on customer ${customerId}:`,
e
);
}
console.log(
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
);
}
}
}
} catch (e) {
console.error(
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
e
);
}
}
/**
* Phase 9: handle setup-mode Checkout completion. The customer
* authorized a card for future off-session charges; persist the
* display fields against their org so the portal can show the
* saved card and use it for auto-charge.
*
* The session carries:
* - mode: 'setup'
* - customer: 'cus_xxx' (the Stripe customer id we created)
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
*
* We look up which org owns the customer (via
* org_billing_config.stripe_customer_id), fetch the SetupIntent
* to find the resulting PaymentMethod id, then fetch the PM for
* its display fields. Three Stripe round-trips total — acceptable
* for a one-off setup event.
*/
async function handleSetupCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
const customerId =
typeof session.customer === "string"
? session.customer
: session.customer?.id;
if (!customerId) {
console.error(
`Setup session ${session.id} completed without a customer; cannot link to org.`
);
return;
}
const orgId = await getOrgIdByStripeCustomerId(customerId);
if (!orgId) {
console.error(
`Setup session ${session.id} for customer ${customerId} has no matching org.`
);
return;
}
const setupIntentId =
typeof session.setup_intent === "string"
? session.setup_intent
: session.setup_intent?.id;
if (!setupIntentId) {
console.error(
`Setup session ${session.id} completed without a setup_intent id.`
);
return;
}
// Read the SetupIntent for the resulting PaymentMethod id.
const stripe = getStripeClient();
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
const paymentMethodId =
typeof setupIntent.payment_method === "string"
? setupIntent.payment_method
: setupIntent.payment_method?.id;
if (!paymentMethodId) {
console.error(
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
);
return;
}
// Fetch the PM details for display columns.
const display = await getPaymentMethodDisplay(paymentMethodId);
await setSavedPaymentMethod({
zitadelOrgId: orgId,
stripeCustomerId: customerId,
paymentMethodId,
brand: display.brand,
last4: display.last4,
expMonth: display.expMonth,
expYear: display.expYear,
});
// Also tell Stripe this PM is the customer's default for invoice
// payments — so a future stripe.paymentIntents.create against
// this customer without an explicit payment_method picks it up.
// Best-effort: a failure here doesn't undo the save (we have the
// pm id, we can pass it explicitly when charging in Phase 9b).
try {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
} catch (e) {
console.warn(
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
e
);
}
console.log(
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
);
} }
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> { async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
interface OrgEntry {
zitadelOrgId: string;
companyName: string | null;
country: string | null;
hasSavedCard: boolean;
cardLabel: string | null;
payByInvoice: boolean;
autoChargeEnabled: boolean;
}
interface Props {
orgs: OrgEntry[];
}
/**
* Inline toggles for pay_by_invoice and auto_charge_enabled per
* org. Each toggle round-trips to /api/admin/billing/orgs/[orgId]
* /payment-mode and then router.refresh() so the server-fetched
* state stays canonical (avoids drift between optimistic UI and
* the DB).
*
* Phase 9b-2.
*/
export function OrgPaymentModeList({ orgs }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState("");
const toggle = async (
orgId: string,
patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean }
) => {
setError("");
setBusy(orgId);
try {
const res = await fetch(
`/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(null);
}
};
if (orgs.length === 0) {
return (
<Card>
<div className="p-6 text-center text-text-secondary text-sm">
{t("orgsEmpty")}
</div>
</Card>
);
}
return (
<Card>
{error && (
<div className="text-sm text-error border-b border-error/30 bg-error/10 px-4 py-2">
{error}
</div>
)}
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pl-3 pr-4">{t("orgsColCustomer")}</th>
<th className="pb-2 pr-4">{t("orgsColCard")}</th>
<th className="pb-2 pr-4 text-center">
{t("orgsColPayByInvoice")}
</th>
<th className="pb-2 pr-4 text-center">
{t("orgsColAutoCharge")}
</th>
</tr>
</thead>
<tbody>
{orgs.map((o) => (
<tr key={o.zitadelOrgId} className="border-t border-border">
<td className="py-2 pl-3 pr-4">
<div className="font-medium">
{o.companyName ?? (
<span className="font-mono text-xs">{o.zitadelOrgId}</span>
)}
</div>
{o.country && (
<div className="text-xs text-text-muted">{o.country}</div>
)}
</td>
<td className="py-2 pr-4 text-xs">
{o.hasSavedCard ? (
<span className="font-mono">{o.cardLabel}</span>
) : (
<span className="text-text-muted">
{t("orgsNoSavedCard")}
</span>
)}
</td>
<td className="py-2 pr-4 text-center">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={o.payByInvoice}
disabled={busy === o.zitadelOrgId}
onChange={(e) =>
toggle(o.zitadelOrgId, {
payByInvoice: e.target.checked,
})
}
/>
<span className="text-xs">
{o.payByInvoice
? t("orgsPayByInvoiceOn")
: t("orgsPayByInvoiceOff")}
</span>
</label>
</td>
<td className="py-2 pr-4 text-center">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={o.autoChargeEnabled}
disabled={busy === o.zitadelOrgId || o.payByInvoice}
onChange={(e) =>
toggle(o.zitadelOrgId, {
autoChargeEnabled: e.target.checked,
})
}
/>
<span className="text-xs">
{o.autoChargeEnabled
? t("orgsAutoChargeOn")
: t("orgsAutoChargeOff")}
</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -26,6 +26,11 @@ interface OnboardingFlowProps {
* validation skip when the billing step was skipped. * validation skip when the billing step was skipped.
*/ */
existingOrgBilling?: OrgBilling | null; existingOrgBilling?: OrgBilling | null;
/**
* Phase 9b: platform setup fee (net CHF) shown on the review
* step. Forwarded straight to the wizard.
*/
setupFeeChf?: number | null;
/** /**
* Bug 6: when present, the wizard is rendered in edit mode against * Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full * the given pending request. See `OnboardingWizard` for the full
@@ -53,6 +58,7 @@ export function OnboardingFlow({
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling, existingOrgBilling,
setupFeeChf,
editingRequest, editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -64,6 +70,7 @@ export function OnboardingFlow({
userEmail={userEmail} userEmail={userEmail}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling} existingOrgBilling={existingOrgBilling}
setupFeeChf={setupFeeChf}
editingRequest={editingRequest} editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages"; import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org"; import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
import { import {
configureStepSchema, configureStepSchema,
billingStepSchema, billingStepSchema,
@@ -108,6 +109,14 @@ interface WizardProps {
* billingAddress snapshot). * billingAddress snapshot).
*/ */
existingOrgBilling?: OrgBilling | null; existingOrgBilling?: OrgBilling | null;
/**
* Phase 9b: the platform's current tenant setup fee (net CHF,
* before VAT). Shown on the review step so the customer sees how
* much they're about to be charged before being sent to Stripe.
* Null/0 means no setup fee — the review notice is suppressed and
* the order skips the Checkout redirect (handled server-side).
*/
setupFeeChf?: number | null;
/** /**
* Bug 6: when present, the wizard renders in "edit" mode — fields * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -147,6 +156,7 @@ export function OnboardingWizard({
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling, existingOrgBilling,
setupFeeChf,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -245,6 +255,14 @@ export function OnboardingWizard({
const [disclaimerAccepted, setDisclaimerAccepted] = useState< const [disclaimerAccepted, setDisclaimerAccepted] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
// Phase 9b: per-channel customer user id collected at onboarding.
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
// admin approval — see /api/admin/requests/[id]/approve. Optional
// per channel; the customer can also leave it blank and add their
// id later from the tenant's channel-users page.
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
{}
);
// Fetch DB-stored defaults on mount // Fetch DB-stored defaults on mount
useEffect(() => { useEffect(() => {
@@ -464,6 +482,20 @@ export function OnboardingWizard({
})() })()
: config; : config;
// Phase 9b: build the channelUsers payload from the per-package
// ids collected during onboarding. Only include channels that
// (a) are enabled in the wizard's packages list AND
// (b) have a non-empty id entered.
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
// — so the approve handler can pass it straight through.
const channelUsersPayload: Record<string, string[]> = {};
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
const trimmed = (rawId ?? "").trim();
if (!trimmed) continue;
if (!config.packages.includes(pkgId)) continue;
channelUsersPayload[pkgId] = [trimmed];
}
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -473,6 +505,10 @@ export function OnboardingWizard({
Object.keys(secretsPayload).length > 0 Object.keys(secretsPayload).length > 0
? secretsPayload ? secretsPayload
: undefined, : undefined,
channelUsers:
Object.keys(channelUsersPayload).length > 0
? channelUsersPayload
: undefined,
}), }),
}); });
@@ -481,6 +517,22 @@ export function OnboardingWizard({
throw new Error(data.error || "Submission failed"); throw new Error(data.error || "Submission failed");
} }
// Phase 9b: if the server initiated a setup-fee Checkout, the
// response carries a `checkoutUrl`. Redirect the browser
// directly — Stripe Checkout is the next step. The
// tenant_requests row is already inserted in 'pending_payment'
// status; on successful Checkout, the webhook flips it to
// 'pending' and admin sees it.
const data = await res.json().catch(() => ({}));
if (data?.checkoutUrl) {
// Don't reset submitting=false — let the redirect happen
// with the spinner still active so the button stays
// disabled.
window.location.href = data.checkoutUrl;
return;
}
// Zero-fee path or PATCH edit — same behaviour as before.
onComplete(); onComplete();
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
@@ -720,7 +772,9 @@ export function OnboardingWizard({
className={`border rounded-lg overflow-hidden transition-colors ${ className={`border rounded-lg overflow-hidden transition-colors ${
isSelected isSelected
? "border-accent bg-accent/5" ? "border-accent bg-accent/5"
: "border-border bg-surface-2" : pkg.recommended
? "border-accent/40 bg-accent/[0.02]"
: "border-border bg-surface-2"
}`} }`}
> >
{/* Toggle row */} {/* Toggle row */}
@@ -739,6 +793,11 @@ export function OnboardingWizard({
> >
{pkg.name} {pkg.name}
</span> </span>
{pkg.recommended && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
{tPkg("recommended")}
</span>
)}
{pkg.requiresSecrets && ( {pkg.requiresSecrets && (
<span className="ml-1.5 text-[10px] text-text-muted"> <span className="ml-1.5 text-[10px] text-text-muted">
({tPkg("requiresApiKey")}) ({tPkg("requiresApiKey")})
@@ -760,8 +819,16 @@ export function OnboardingWizard({
</div> </div>
</button> </button>
{/* Inline credential inputs — expand when selected + requires secrets */} {/* Inline expansion when selected — shows
{isSelected && pkg.requiresSecrets && ( instructions (if any), credential inputs
(if requiresSecrets), and the disclaimer
checkbox (if any). Threema for example
has no customer-entered secrets but has
instructions + a disclaimer to accept. */}
{isSelected &&
(pkg.requiresSecrets ||
pkg.instructionsKey ||
pkg.disclaimerKey) && (
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50"> <div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
{pkg.instructionsKey && ( {pkg.instructionsKey && (
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line"> <div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
@@ -774,6 +841,40 @@ export function OnboardingWizard({
</div> </div>
)} )}
{/* Threema: show the bot's Threema ID
and QR right here in the wizard. The
instructions text refers to a QR
that isn't visible until after
provisioning — without this block
the message is confusing. The QR is
the platform's shared gateway QR
(*AIAGENT), identical for every
tenant, so we can render it before
the tenant even exists. */}
{pkg.id === "threema" && (
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
<div className="bg-white p-1.5 rounded-md shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
width={96}
height={96}
style={{ display: "block" }}
/>
</div>
<div className="text-xs text-text-secondary leading-relaxed">
<div className="text-text-primary font-medium mb-1">
{tPkg("threemaBotIdHeading")}
</div>
<div className="font-mono text-sm text-accent mb-2">
{THREEMA_GATEWAY.displayName}
</div>
<div>{tPkg("threemaBotIdHint")}</div>
</div>
</div>
)}
{(pkg.secrets || []).map((field) => ( {(pkg.secrets || []).map((field) => (
<label key={field.key} className="block"> <label key={field.key} className="block">
<span className="text-xs text-text-secondary mb-1 block"> <span className="text-xs text-text-secondary mb-1 block">
@@ -802,6 +903,46 @@ export function OnboardingWizard({
</label> </label>
))} ))}
{/* Phase 9b: channel-user-id capture
during onboarding. For channels
where the customer's own user id
is needed for routing (Telegram,
Discord, Threema), collect it here
so the assistant is usable
immediately on provisioning. The
help text comes from the existing
channelUsers.<id>IdHelp keys
(same copy as the post-provisioning
page uses). Field is optional —
blank means "I'll add it later". */}
{pkg.collectsChannelUserId && (
<label className="block">
<span className="text-xs text-text-secondary mb-1 block">
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
<span className="text-text-muted normal-case">
({t("optional")})
</span>
</span>
<input
type="text"
placeholder={t(
`yourChannelIdPlaceholder.${pkg.id}`
)}
value={channelUserIds[pkg.id] ?? ""}
onChange={(e) =>
setChannelUserIds((prev) => ({
...prev,
[pkg.id]: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
{t(`yourChannelIdHelp.${pkg.id}`)}
</p>
</label>
)}
{pkg.disclaimerKey && ( {pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-text-secondary"> <label className="flex items-start gap-2 text-xs text-text-secondary">
<input <input
@@ -1030,28 +1171,6 @@ export function OnboardingWizard({
</p> </p>
</FieldWithError> </FieldWithError>
)} )}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingNotes")}
</label>
<textarea
value={config.billingNotes}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingNotes: e.target.value,
}))
}
rows={3}
placeholder={t(
isPersonal
? "billingNotesPlaceholderPersonal"
: "billingNotesPlaceholder"
)}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
/>
</div>
</div> </div>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
@@ -1213,19 +1332,34 @@ export function OnboardingWizard({
value={userEmail || ""} value={userEmail || ""}
mono mono
/> />
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div> </div>
<p className="text-xs text-text-muted">{t("confirmNote")}</p> <p className="text-xs text-text-muted">{t("confirmNote")}</p>
{/* Phase 9b: order-time setup-fee notice + amount. The
figure shown is the net platform fee (before VAT);
VAT is added server-side based on the billing
country. We show "+ VAT" rather than a computed
gross to avoid mis-displaying a country-dependent
total. If setupFeeChf is null/0, no charge happens
and the whole block is suppressed. */}
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1">
{t("setupFeeNoticeHeading")}
</strong>
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
<span>{t("setupFeeAmountLabel")}</span>
<span className="text-sm font-semibold text-text-primary">
CHF {setupFeeChf.toFixed(2)}{" "}
<span className="text-[10px] font-normal text-text-muted">
{t("setupFeePlusVat")}
</span>
</span>
</div>
{t("setupFeeNoticeBody")}
</div>
)}
</div> </div>
{error && ( {error && (

View File

@@ -9,6 +9,7 @@ import type {
SkillPricing, SkillPricing,
} from "@/types"; } from "@/types";
import { SkillCostDialog } from "./skill-cost-dialog"; import { SkillCostDialog } from "./skill-cost-dialog";
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
interface Props { interface Props {
pkg: PackageDef; pkg: PackageDef;
@@ -51,6 +52,11 @@ export function PackageCard({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Phase 2.5: cost-disclosure flow + activation-request flow. // Phase 2.5: cost-disclosure flow + activation-request flow.
const [showCostDialog, setShowCostDialog] = useState(false); const [showCostDialog, setShowCostDialog] = useState(false);
// Threema: after a successful enable on customProvisioning, surface
// the gateway QR + bot Threema ID so the customer immediately knows
// how to add the assistant to their Threema contacts. Without this,
// the toggle just flips silently with no actionable info.
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
const isPriced = const isPriced =
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0; (pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
@@ -79,6 +85,14 @@ export function PackageCard({
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`); throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
} }
await togglePackage(true); await togglePackage(true);
// For Threema specifically: now that the relay's minted the
// per-tenant token and the package is enabled, show the
// gateway QR + bot Threema ID so the customer can add the
// assistant to their Threema contacts straight away. Other
// customProvisioning packages don't need this confirmation.
if (pkg.id === "threema") {
setShowThreemaInfo(true);
}
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
} finally { } finally {
@@ -283,17 +297,33 @@ export function PackageCard({
</button> </button>
</div> </div>
) : canEdit ? ( ) : canEdit ? (
<button <div className="ml-auto flex items-center gap-2">
onClick={enabled ? handleDisable : handleEnable} {/* Phase 9b: re-open the Threema info popup at any time
disabled={saving} while Threema is enabled. The popup auto-opens after
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${ a fresh enable; this button lets the customer see the
enabled QR + bot ID again without having to disable + re-enable. */}
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2" {pkg.id === "threema" && enabled && (
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20" <button
} disabled:opacity-50`} onClick={() => setShowThreemaInfo(true)}
> className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")} title={t("packages.showInfoTitle")}
</button> aria-label={t("packages.showInfoTitle")}
>
{t("packages.showInfo")}
</button>
)}
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
</div>
) : ( ) : (
// Slice 5: read-only viewers see a static badge instead of a // Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys // toggle. The status badge above the divider already conveys
@@ -320,6 +350,16 @@ export function PackageCard({
busy={saving} busy={saving}
/> />
{/* Threema: post-enable confirmation showing the gateway QR
and bot Threema ID. Only rendered for the threema package
and only after a successful enable. The same modal is also
reachable later on the channel-users page. */}
{pkg.id === "threema" && (
<ThreemaQrModal
open={showThreemaInfo}
onClose={() => setShowThreemaInfo(false)}
/>
)}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <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"> <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

@@ -0,0 +1,272 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { OrgBillingConfig } from "@/types";
interface Props {
config: OrgBillingConfig | null;
/**
* True when this org has been flipped to pay-by-invoice by admin.
* The card UI still renders (admin-set customers might also have
* a saved card as backup), but with an info note that auto-charge
* is disabled by their billing mode.
*/
isPayByInvoice: boolean;
/**
* Personal-account flag from the session. Personal accounts are
* single-user B2C tenants and don't have the bank-transfer
* affordance — they pay by card or not at all. We hide the
* "Bank transfer is available on request" hint for these accounts
* to keep the messaging unambiguous.
*/
isPersonal: boolean;
}
const BRAND_LABELS: Record<string, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "American Express",
discover: "Discover",
jcb: "JCB",
diners: "Diners Club",
unionpay: "UnionPay",
};
/**
* Saved-card management — Phase 9.
*
* State derives entirely from the OrgBillingConfig the server
* sends down. Actions are: set up (no card → Checkout setup
* mode), update (existing card → same Checkout flow, replaces),
* remove (DELETE the PM in Stripe + clear local fields), toggle
* auto-charge.
*
* The component watches for ?card_setup=success on mount and
* fires a router.refresh() — the success redirect from Stripe
* lands here and the new card info needs to load. We also strip
* the query param so a page reload doesn't re-trigger.
*/
export function SavedCardSection({
config,
isPayByInvoice,
isPersonal,
}: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const searchParams = useSearchParams();
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
const [error, setError] = useState("");
// Refresh + clean the URL when Stripe redirects back. Stripe's
// webhook is what actually persists the card; the refresh just
// re-fetches the server-side config so the new fields appear.
useEffect(() => {
const status = searchParams.get("card_setup");
if (status === "success") {
router.replace("/settings/billing");
router.refresh();
} else if (status === "cancelled") {
// Just clean the URL. No-op otherwise.
router.replace("/settings/billing");
}
}, [searchParams, router]);
const hasCard = !!config?.stripeDefaultPaymentMethodId;
const autoChargeOn = config?.autoChargeEnabled !== false;
const startSetup = async () => {
setError("");
setBusy("setup");
try {
const res = await fetch("/api/billing/setup-card", { method: "POST" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
if (!j.url) throw new Error("No redirect URL returned");
// Hard-redirect — Stripe Checkout doesn't run inside the SPA.
window.location.href = j.url;
} catch (e: any) {
setError(e.message);
setBusy(null);
}
};
const removeCard = async () => {
if (!confirm(t("savedCardRemoveConfirm"))) return;
setError("");
setBusy("remove");
try {
const res = await fetch("/api/billing/saved-card", { method: "DELETE" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(null);
}
};
// Empty state — no card on file.
if (!hasCard) {
return (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<p className="text-sm text-text-secondary mb-4">
{t("savedCardEmptyBody")}
</p>
{/* Phase 9: prominent policy notice. Auto-pay is the
expected default — emphasise that failure to keep a
chargeable card on file may result in tenant suspension.
Sits above the CTA so it's seen before the click. */}
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
<strong className="block mb-1">
{t("savedCardAutoPayRequiredHeading")}
</strong>
<span className="text-text-secondary">
{t("savedCardAutoPayRequiredBody")}
</span>
</div>
{error && (
<div className="text-sm text-error mb-3">{error}</div>
)}
<button
onClick={startSetup}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
</button>
{/* Bank-transfer hint shown only for company accounts.
Personal (B2C) accounts pay by card only — surfacing
the alternative would only confuse. */}
{!isPersonal && (
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
)}
</div>
</Card>
);
}
// Card on file.
const brandLabel =
config?.stripePmBrand
? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand
: t("savedCardBrandUnknown");
const last4 = config?.stripePmLast4 ?? "????";
const expMonth = config?.stripePmExpMonth;
const expYear = config?.stripePmExpYear;
const expLabel =
expMonth && expYear
? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}`
: "";
// Heuristic for "expiring soon" — if the card expires this calendar
// month or next. Stripe's pre-expiration emails handle the real
// notification, but a portal hint is friendly too.
const now = new Date();
const expiringSoon =
expMonth &&
expYear &&
(expYear < now.getFullYear() ||
(expYear === now.getFullYear() && expMonth <= now.getMonth() + 2));
return (
<Card>
<CardHeader>{t("savedCardHeading")}</CardHeader>
<div className="p-5">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<span className="font-mono text-sm">
{brandLabel} {last4}
</span>
{expLabel && (
<span
className={`text-xs ${
expiringSoon ? "text-warning" : "text-text-muted"
}`}
>
{t("savedCardExpires", { date: expLabel })}
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs">
<span
className={`px-2 py-0.5 rounded text-xs ${
autoChargeOn
? "bg-success/15 text-success"
: "bg-text-muted/15 text-text-muted"
}`}
>
{autoChargeOn
? t("savedCardAutoChargeOn")
: t("savedCardAutoChargeOff")}
</span>
</div>
</div>
{isPayByInvoice && (
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
{t("savedCardPayByInvoiceNote")}
</div>
)}
{/* If the card is on file but the customer has actively
disabled auto-pay, surface the suspension-risk reminder.
Not shown when admin has flipped them to pay-by-invoice —
that's a different deal and the note above explains it. */}
{!isPayByInvoice && !autoChargeOn && (
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
{t("savedCardAutoPayDisabledNote")}
</div>
)}
{error && <div className="text-sm text-error mb-3">{error}</div>}
<div className="flex gap-2 flex-wrap">
<button
onClick={startSetup}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "setup"
? t("savedCardRedirecting")
: t("savedCardUpdateBtn")}
</button>
<button
onClick={removeCard}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
>
{busy === "remove"
? t("savedCardRemoving")
: t("savedCardRemoveBtn")}
</button>
</div>
{/* Bank-transfer hint shown only for company accounts. */}
{!isPersonal && (
<p className="text-xs text-text-muted mt-4">
{t("savedCardBankTransferHint")}{" "}
<a
href="/support"
className="text-accent hover:underline"
>
{t("savedCardBankTransferLink")}
</a>
</p>
)}
</div>
</Card>
);
}

View File

@@ -60,8 +60,10 @@ import {
listSkillEventsForTenant, listSkillEventsForTenant,
listSkillPricing, listSkillPricing,
listSuspensionEventsForTenant, listSuspensionEventsForTenant,
markInvoicePaid,
markInvoiceVoided, markInvoiceVoided,
recordInvoiceRefund, recordInvoiceRefund,
setInvoiceStripePaymentIntent,
tenantHasSetupFeeBilled, tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled, tenantSkillHasBeenBilled,
updateInvoicePdf, updateInvoicePdf,
@@ -71,8 +73,12 @@ import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay"; import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf"; import { renderInvoicePdf } from "./billing-pdf";
import { renderCreditNotePdf } from "./credit-note-pdf"; import { renderCreditNotePdf } from "./credit-note-pdf";
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email"; import {
import { createInvoiceRefund } from "./stripe"; sendAutoChargeFailedEmail,
sendCreditNoteEmail,
sendInvoiceIssuedEmail,
} from "./email";
import { chargeInvoiceOffSession, createInvoiceRefund } from "./stripe";
import { formatLineDescription } from "./billing-i18n"; import { formatLineDescription } from "./billing-i18n";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -796,50 +802,90 @@ export async function generateInvoice(opts: {
await updateInvoicePdf(placeholder.id, pdfBuffer, filename); await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
const finalInvoice = await getInvoiceById(placeholder.id); const finalInvoice = await getInvoiceById(placeholder.id);
// Phase 3: best-effort notification to the billing contact. // Phase 9b-2: attempt off-session auto-charge BEFORE sending
// We send AFTER the PDF is fully persisted (so the deep link // any email. This drives which email goes out:
// in the email immediately resolves to a downloadable PDF) but // - Charge succeeded: skip the "your invoice is ready" email
// BEFORE returning, since the cron caller doesn't otherwise // (would be misleading — invoice is already paid). Stripe
// know to trigger this. Failure is logged, never thrown — a // sends an automated receipt to billingSnapshot.billingEmail.
// mail-server hiccup must not roll back an issued invoice. // - Charge failed: send the auto-charge-failed email instead
// The recipient is the billing email captured in the invoice // of the regular issued email (clear action: pay manually).
// snapshot (immutable; reflects who was on file at issue time). // - Charge skipped (pay_by_invoice / no card / disabled):
try { // send the regular "your invoice is ready" email — that's
const settled = finalInvoice ?? placeholder; // the only signal the customer gets.
const snapshot = settled.billingSnapshot; const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
if (snapshot.billingEmail) { const settled =
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [ chargeOutcome.kind === "succeeded"
"en", "de", "fr", "it", ? (await getInvoiceById(placeholder.id)) ?? finalInvoice ?? placeholder
]; : finalInvoice ?? placeholder;
const locale = supportedLocales.includes(settled.locale as any) const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
? (settled.locale as "en" | "de" | "fr" | "it") "en", "de", "fr", "it",
: "de"; ];
await sendInvoiceIssuedEmail({ const emailLocale = supportedLocales.includes(settled.locale as any)
to: snapshot.billingEmail, ? (settled.locale as "en" | "de" | "fr" | "it")
contactName: snapshot.companyName, // no separate contact-name field : "de";
companyName: snapshot.companyName, const snapshot = settled.billingSnapshot;
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf, if (chargeOutcome.kind === "succeeded") {
currency: "CHF", console.log(
dueAt: settled.dueAt, `Invoice ${settled.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
lineCount: draft.lines.length, );
periodStart: settled.periodStart, } else if (chargeOutcome.kind === "failed") {
periodEnd: settled.periodEnd, // Send the auto-charge-failed email (not the regular issued
locale, // email). The customer should be told the charge failed and
}); // pointed to the manual-pay flow.
} else { try {
console.warn( if (snapshot.billingEmail) {
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.` await sendAutoChargeFailedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName,
companyName: snapshot.companyName,
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf,
currency: "CHF",
dueAt: settled.dueAt,
reasonForCustomer: chargeOutcome.reasonForCustomer,
locale: emailLocale,
});
}
} catch (e) {
console.error(
`Invoice ${settled.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
e
);
}
} else {
// Skipped — pay-by-invoice / disabled / no card. Send the
// regular issued email so the customer knows there's
// something to pay.
try {
if (snapshot.billingEmail) {
await sendInvoiceIssuedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName,
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: emailLocale,
});
} 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
); );
} }
} catch (e) {
console.error(
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
e
);
} }
return { draft, invoice: finalInvoice ?? placeholder }; return { draft, invoice: settled };
} catch (e) { } catch (e) {
// Render failed — leave the persisted row in place so admin can // Render failed — leave the persisted row in place so admin can
// inspect it, but surface the error. // inspect it, but surface the error.
@@ -1435,29 +1481,67 @@ export async function issueCustomInvoiceDraft(params: {
// future tool (Phase 8.5 or just by deleting+reissuing). // future tool (Phase 8.5 or just by deleting+reissuing).
} }
// Best-effort email. // Phase 9b-2: same auto-charge + email branching as the cron
try { // path. Custom invoices go through the same gate: pay_by_invoice
const snap = invoiceDraft.billingSnapshot; // / auto_charge_enabled / saved card determine whether we attempt
if (snap.billingEmail) { // the charge.
await sendInvoiceIssuedEmail({ const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id);
to: snap.billingEmail, const settledCustom =
contactName: snap.contactName || snap.companyName, chargeOutcome.kind === "succeeded"
companyName: snap.companyName, ? (await getInvoiceById(placeholder.id)) ?? placeholder
invoiceNumber: placeholder.invoiceNumber, : placeholder;
totalChf: placeholder.totalChf,
currency: "CHF", if (chargeOutcome.kind === "succeeded") {
dueAt: placeholder.dueAt, console.log(
lineCount: invoiceDraft.lines.length, `Custom invoice ${settledCustom.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.`
periodStart: null,
periodEnd: null,
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
});
}
} catch (e) {
console.error(
`Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`,
e
); );
} else if (chargeOutcome.kind === "failed") {
try {
const snap = invoiceDraft.billingSnapshot;
if (snap.billingEmail) {
await sendAutoChargeFailedEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
invoiceNumber: settledCustom.invoiceNumber,
totalChf: settledCustom.totalChf,
currency: "CHF",
dueAt: settledCustom.dueAt,
reasonForCustomer: chargeOutcome.reasonForCustomer,
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
});
}
} catch (e) {
console.error(
`Custom invoice ${settledCustom.invoiceNumber} auto-charge failed; failed-charge email also failed:`,
e
);
}
} else {
// Skipped — send the regular issued email.
try {
const snap = invoiceDraft.billingSnapshot;
if (snap.billingEmail) {
await sendInvoiceIssuedEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
invoiceNumber: settledCustom.invoiceNumber,
totalChf: settledCustom.totalChf,
currency: "CHF",
dueAt: settledCustom.dueAt,
lineCount: invoiceDraft.lines.length,
periodStart: null,
periodEnd: null,
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
});
}
} catch (e) {
console.error(
`Custom invoice ${settledCustom.invoiceNumber} issued; email send failed.`,
e
);
}
} }
// Draft did its job — remove it. If this fails the issuance // Draft did its job — remove it. If this fails the issuance
@@ -1471,7 +1555,7 @@ export async function issueCustomInvoiceDraft(params: {
); );
} }
return placeholder; return settledCustom;
} }
/** /**
@@ -1539,3 +1623,240 @@ export async function renderCustomDraftPreview(
})) }))
); );
} }
// ---------------------------------------------------------------------------
// Phase 9b — tenant setup-fee invoice at order time
// ---------------------------------------------------------------------------
/**
* Build and persist the one-line custom invoice that captures
* the tenant setup fee at order time. The customer is then
* redirected to Stripe Checkout to pay it.
*
* - source = 'custom' so the monthly cron's per-period uniqueness
* guard (partial index WHERE source='auto') doesn't interfere
* - line.kind = 'tenant_setup' so the monthly cron's setup-fee
* dedup (tenantHasSetupFeeBilled) sees this as the setup fee
* billing event for the future tenant
* - line.tenant_name = the derived name (computed from request id
* via deriveTenantName) so the dedup query finds the line
* - period_start / period_end stay null (no billing period)
* - issuedAt = now (no override)
* - dueAt = same day (charge happens immediately via Checkout)
*
* VAT uses the same vatRateForAddress() logic as the monthly cron
* and the admin custom-invoice flow.
*/
export async function createTenantSetupFeeInvoice(params: {
zitadelOrgId: string;
tenantName: string;
billingSnapshot: InvoiceBillingSnapshot;
locale: "de" | "en" | "fr" | "it";
paymentMethod: InvoicePaymentMethod;
}): Promise<Invoice> {
const platformPricing = await getPlatformPricing();
const setupFeeChf = platformPricing.tenantSetupFeeChf;
if (setupFeeChf <= 0) {
throw new Error(
"createTenantSetupFeeInvoice called but tenant_setup_fee_chf is 0 — caller should skip the charge flow entirely."
);
}
const vat = vatRateForAddress(params.billingSnapshot, platformPricing);
const subtotalChf = setupFeeChf;
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
// tenant_name on the line is the dedup anchor. metadata empty —
// tenant_setup lines from the monthly cron also carry no metadata
// beyond what billing-i18n needs, which is just the kind itself.
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [
{
tenantName: params.tenantName,
kind: "tenant_setup" as InvoiceLineKind,
description: formatLineDescription(
{ kind: "tenant_setup", tenantName: params.tenantName, metadata: null },
params.locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: setupFeeChf,
amountChf: setupFeeChf,
metadata: null,
displayOrder: 0,
},
];
const today = new Date().toISOString().slice(0, 10);
const draft: InvoiceDraft = {
zitadelOrgId: params.zitadelOrgId,
source: "custom",
periodStart: null,
periodEnd: null,
issuedAt: undefined, // let createInvoice default to now()
dueAt: today,
locale: params.locale,
paymentMethod: params.paymentMethod,
billingSnapshot: params.billingSnapshot,
lines,
subtotalChf,
vatRate: vat.rate,
vatAmountChf,
totalChf,
warnings: [],
};
// Persist without PDF — the PDF render here would block the
// Checkout redirect path and isn't needed for the customer's
// payment step. Render lazily after payment succeeds (Phase 9c
// candidate); for now the invoice carries no PDF until then.
// It'll still appear on /billing for the customer; the download
// button will be disabled (hasPdf = false) until a render lands.
const invoice = await createInvoice(draft, null, null);
// Best-effort: render the PDF asynchronously so the customer
// has it on /billing soon after paying. The async fire-and-
// forget pattern: failures only log, the invoice row stays
// valid either way.
renderInvoicePdf(
invoice,
lines.map((l, i) => ({
...l,
id: `tmp-${i}`,
invoiceId: invoice.id,
}))
)
.then((pdf) =>
updateInvoicePdf(invoice.id, pdf, `${invoice.invoiceNumber}.pdf`)
)
.catch((e) =>
console.error(
`Setup-fee invoice ${invoice.invoiceNumber} PDF render failed (async):`,
e
)
);
return invoice;
}
// ---------------------------------------------------------------------------
// Phase 9b-2 — recurring off-session auto-charge
// ---------------------------------------------------------------------------
export type AutoChargeOutcome =
| { kind: "skipped"; reason: string }
| { kind: "succeeded"; paymentIntentId: string }
| { kind: "failed"; reasonForCustomer: string; code?: string };
/**
* Reduce a Stripe decline code into a short, locale-neutral string
* the customer can read. We never put the raw Stripe message in
* an email (it can leak BIN, country, etc.); this maps known codes
* to safe equivalents and falls back to a generic "card was
* declined" string for unknown codes.
*
* Phase 9b-2 keeps this in English only — the email template
* translates the surrounding copy, and the reason itself is short
* enough that admin can decide later whether to localize it.
*/
function describeDeclineCode(code: string | undefined, fallback: string): string {
if (!code) return fallback;
const map: Record<string, string> = {
card_declined: "Card was declined by the issuer.",
expired_card: "Card has expired.",
insufficient_funds: "Insufficient funds.",
incorrect_cvc: "Card security code (CVC) was incorrect.",
processing_error: "Card processing error at the issuer.",
authentication_required: "Authentication required (3D Secure).",
do_not_honor: "Card was declined by the issuer (do not honor).",
pickup_card: "Card cannot be used — please contact the issuer.",
lost_card: "Card was reported lost.",
stolen_card: "Card was reported stolen.",
generic_decline: "Card was declined.",
};
return map[code] ?? fallback;
}
/**
* Decide whether an invoice can be auto-charged and attempt it.
*
* Gates (in order — first match wins):
* 1. Invoice not in 'open' status → skip ("not_open")
* 2. org_billing_config.pay_by_invoice = true → skip ("pay_by_invoice")
* (admin override for bank-transfer customers)
* 3. org_billing_config.auto_charge_enabled = false → skip ("disabled")
* 4. No saved payment method id → skip ("no_card")
* 5. No Stripe customer id → skip ("no_customer") — shouldn't happen
* if PM is saved (the setup flow creates one) but defensive
*
* On charge attempt:
* - succeeded: markInvoicePaid + return outcome
* - declined / requires_action: leave invoice open, return reason
* for the caller to send the auto-charge-failed email
*
* This function is idempotent on the invoice side (markInvoicePaid
* is a no-op if already paid). Calling twice in rapid succession
* may cause two Stripe charges if both attempts pass the gates —
* the caller (generateInvoice / issueCustomInvoiceDraft) only
* calls once per issuance and is the natural single-shot guard.
*/
export async function chargeInvoiceIfPossible(
invoiceId: string
): Promise<AutoChargeOutcome> {
const invoice = await getInvoiceById(invoiceId);
if (!invoice) {
return { kind: "skipped", reason: "invoice_not_found" };
}
if (invoice.status !== "open") {
return { kind: "skipped", reason: `not_open (status=${invoice.status})` };
}
const cfg = await getOrgBillingConfig(invoice.zitadelOrgId);
if (cfg.payByInvoice) {
return { kind: "skipped", reason: "pay_by_invoice" };
}
if (cfg.autoChargeEnabled === false) {
return { kind: "skipped", reason: "disabled" };
}
if (!cfg.stripeDefaultPaymentMethodId) {
return { kind: "skipped", reason: "no_card" };
}
if (!cfg.stripeCustomerId) {
return { kind: "skipped", reason: "no_customer" };
}
const outcome = await chargeInvoiceOffSession({
invoice,
customerId: cfg.stripeCustomerId,
paymentMethodId: cfg.stripeDefaultPaymentMethodId,
receiptEmail: invoice.billingSnapshot.billingEmail ?? null,
});
if (outcome.status === "succeeded") {
// Persist the PI id + flip to paid in one shot. markInvoicePaid
// is idempotent (returns null if already paid).
await setInvoiceStripePaymentIntent(invoice.id, outcome.paymentIntentId);
await markInvoicePaid(invoice.id, {
paidBy: "stripe",
paidMethodDetail: `Auto-charge (${outcome.paymentIntentId})`,
});
return { kind: "succeeded", paymentIntentId: outcome.paymentIntentId };
}
// Map outcome to a customer-safe reason string.
if (outcome.status === "requires_action") {
return {
kind: "failed",
reasonForCustomer:
"Authentication required (3D Secure). Please pay manually so your bank can complete verification.",
code: "authentication_required",
};
}
// declined
return {
kind: "failed",
reasonForCustomer: describeDeclineCode(outcome.code, outcome.reason),
code: outcome.code,
};
}

View File

@@ -93,6 +93,26 @@ const MIGRATION_SQL = `
-- is only meaningful for rejected and cancelled rows. -- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Phase 9b: link a provision request to the paid setup-fee invoice
-- it was charged against at order time. Null on requests created
-- before Phase 9b, on resume requests, and during the brief
-- 'pending_payment' window before the Stripe webhook fires. The
-- admin reject flow refunds this invoice via the existing
-- refundInvoice helper.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice
ON tenant_requests(setup_invoice_id)
WHERE setup_invoice_id IS NOT NULL;
-- Phase 9b: optional initial channel-user ids per channel package
-- collected during onboarding. JSONB so the shape can vary by
-- channel (today it's a string[] per channel id, matching
-- PiecedTenantSpec.channelUsers). Default '{}' so reads on legacy
-- rows return an empty object rather than null.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS channel_users JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Feature 6: free-form customer note attached to the request. -- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer -- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic -- explains why they want reactivation), but the column is generic
@@ -421,6 +441,28 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Phase 9: saved-card columns. The PaymentMethod id ('pm_xxx')
-- is the handle for off-session charges; brand/last4/exp are
-- display fields. No PAN, CVV, or anything PCI-scope — Stripe
-- holds those. The columns are nullable because a fresh org has
-- no saved card; setting up auto-pay populates them via the
-- checkout.session.completed webhook in setup mode.
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_default_payment_method_id TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_brand TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_last4 TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_exp_month INTEGER;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_exp_year INTEGER;
-- Phase 9: off-session auto-charge gate. Default TRUE — new orgs
-- pay by card automatically when an invoice is issued (assuming
-- they've also set up a saved card). Admin can flip OFF to pause
-- charging without removing the saved card.
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS auto_charge_enabled BOOLEAN NOT NULL DEFAULT TRUE;
-- Stripe payment methods. Populated by the Phase 4 webhook handler. -- Stripe payment methods. Populated by the Phase 4 webhook handler.
-- Created in Phase 1 so all billing schema is together; rows are -- Created in Phase 1 so all billing schema is together; rows are
@@ -862,8 +904,8 @@ export async function createTenantRequest(
(zitadel_org_id, zitadel_user_id, company_name, instance_name, (zitadel_org_id, zitadel_user_id, company_name, instance_name,
contact_name, contact_email, agent_name, soul_md, agents_md, contact_name, contact_email, agent_name, soul_md, agents_md,
packages, billing_address, billing_notes, encrypted_secrets, packages, billing_address, billing_notes, encrypted_secrets,
is_personal) is_personal, channel_users)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
RETURNING *`, RETURNING *`,
[ [
params.zitadelOrgId, params.zitadelOrgId,
@@ -880,6 +922,7 @@ export async function createTenantRequest(
params.billingNotes, params.billingNotes,
params.encryptedSecrets ?? null, params.encryptedSecrets ?? null,
params.isPersonal ?? false, params.isPersonal ?? false,
JSON.stringify(params.channelUsers ?? {}),
] ]
); );
return mapRow(result.rows[0]); return mapRow(result.rows[0]);
@@ -978,13 +1021,18 @@ export async function listTenantRequests(
status?: TenantRequestStatus status?: TenantRequestStatus
): Promise<TenantRequest[]> { ): Promise<TenantRequest[]> {
await ensureSchema(); await ensureSchema();
// Phase 9b: 'pending_payment' rows are pre-Checkout: the customer
// submitted the wizard but hasn't paid the setup fee yet. They're
// invisible to admin until the webhook flips them to 'pending'.
// The explicit filter path still allows querying them (e.g.
// ?status=pending_payment) for debugging.
const result = status const result = status
? await getPool().query<TenantRequest>( ? await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
[status] [status]
) )
: await getPool().query<TenantRequest>( : await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests ORDER BY created_at DESC" "SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC"
); );
return result.rows.map(mapRow); return result.rows.map(mapRow);
} }
@@ -1409,6 +1457,8 @@ function mapRow(row: any): TenantRequest {
status: row.status as TenantRequestStatus, status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes, adminNotes: row.admin_notes,
tenantName: row.tenant_name, tenantName: row.tenant_name,
setupInvoiceId: row.setup_invoice_id ?? null,
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
encryptedSecrets: row.encrypted_secrets ?? null, encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false, isPersonal: row.is_personal ?? false,
dismissedAt: dismissedAt:
@@ -2250,6 +2300,15 @@ function rowToOrgBillingConfig(row: any): OrgBillingConfig {
stripeCustomerId: row.stripe_customer_id ?? null, stripeCustomerId: row.stripe_customer_id ?? null,
autoInvoiceEnabled: row.auto_invoice_enabled, autoInvoiceEnabled: row.auto_invoice_enabled,
autoRemindersEnabled: row.auto_reminders_enabled, autoRemindersEnabled: row.auto_reminders_enabled,
stripeDefaultPaymentMethodId: row.stripe_default_payment_method_id ?? null,
stripePmBrand: row.stripe_pm_brand ?? null,
stripePmLast4: row.stripe_pm_last4 ?? null,
stripePmExpMonth:
row.stripe_pm_exp_month != null ? Number(row.stripe_pm_exp_month) : null,
stripePmExpYear:
row.stripe_pm_exp_year != null ? Number(row.stripe_pm_exp_year) : null,
autoChargeEnabled:
row.auto_charge_enabled === undefined ? true : !!row.auto_charge_enabled,
createdAt: row.created_at?.toISOString?.() ?? row.created_at, createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
}; };
@@ -3342,15 +3401,28 @@ export async function listAutoIssueOrgIds(): Promise<string[]> {
*/ */
export async function listInvoicesPendingReminders(): Promise<Invoice[]> { export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
await ensureSchema(); await ensureSchema();
// Bug fix: the prior version did `FROM invoices i JOIN org_billing_config c ON c.zitadel_org_id = i.zitadel_org_id`,
// but INVOICE_LIST_COLUMNS selects unqualified column names —
// `zitadel_org_id` appears in both tables and Postgres rejects
// it as ambiguous. Rewriting as a subquery filter keeps every
// referenced column on the single `invoices` row source, so the
// unqualified list stays valid (matching every other caller of
// INVOICE_LIST_COLUMNS in this file).
//
// Semantics are unchanged: only include invoices belonging to
// orgs that have opted into auto-reminders. Orgs with no
// org_billing_config row at all are excluded (same as before —
// the inner join didn't match for them either).
const result = await getPool().query( const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} `SELECT ${INVOICE_LIST_COLUMNS}
FROM invoices i FROM invoices
JOIN org_billing_config c WHERE status IN ('open','overdue')
ON c.zitadel_org_id = i.zitadel_org_id AND due_at < now() - INTERVAL '7 days'
AND c.auto_reminders_enabled = TRUE AND zitadel_org_id IN (
WHERE i.status IN ('open','overdue') SELECT zitadel_org_id FROM org_billing_config
AND i.due_at < now() - INTERVAL '7 days' WHERE auto_reminders_enabled = TRUE
ORDER BY i.due_at ASC` )
ORDER BY due_at ASC`
); );
return result.rows.map(rowToInvoice); return result.rows.map(rowToInvoice);
} }
@@ -3972,3 +4044,264 @@ export async function deleteInvoiceDraft(id: string): Promise<boolean> {
); );
return (result.rowCount ?? 0) > 0; return (result.rowCount ?? 0) > 0;
} }
// ---------------------------------------------------------------------------
// Phase 9 — saved-card management for off-session auto-charge
// ---------------------------------------------------------------------------
/**
* Persist a saved PaymentMethod against an org's billing config.
* Called from the webhook after a successful setup-mode Checkout
* session, and again when "Pay by Card" with setup_future_usage
* delivers a fresh PaymentMethod. Upserts the config row in case
* the org has none yet (rare — onboarding usually creates one,
* but defensive doesn't hurt).
*
* Only display fields (brand/last4/exp) are persisted. The full PAN
* is never seen by this code — Stripe holds it.
*/
export async function setSavedPaymentMethod(params: {
zitadelOrgId: string;
stripeCustomerId: string;
paymentMethodId: string;
brand: string | null;
last4: string | null;
expMonth: number | null;
expYear: number | null;
}): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO org_billing_config (
zitadel_org_id, stripe_customer_id,
stripe_default_payment_method_id, stripe_pm_brand, stripe_pm_last4,
stripe_pm_exp_month, stripe_pm_exp_year, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (zitadel_org_id) DO UPDATE SET
stripe_customer_id = COALESCE(org_billing_config.stripe_customer_id, EXCLUDED.stripe_customer_id),
stripe_default_payment_method_id = EXCLUDED.stripe_default_payment_method_id,
stripe_pm_brand = EXCLUDED.stripe_pm_brand,
stripe_pm_last4 = EXCLUDED.stripe_pm_last4,
stripe_pm_exp_month = EXCLUDED.stripe_pm_exp_month,
stripe_pm_exp_year = EXCLUDED.stripe_pm_exp_year,
updated_at = now()`,
[
params.zitadelOrgId,
params.stripeCustomerId,
params.paymentMethodId,
params.brand,
params.last4,
params.expMonth,
params.expYear,
]
);
}
/**
* Clear the saved PaymentMethod fields. Used when the customer
* clicks "Remove card" — the Stripe-side detach happens in the
* caller (stripe.detachPaymentMethod); this just nulls the
* portal-side display fields and the pm id reference.
*
* Does not touch stripe_customer_id (the customer object survives),
* auto_charge_enabled, or any other config — only the four card
* fields and the pm id pointer.
*/
export async function clearSavedPaymentMethod(
zitadelOrgId: string
): Promise<void> {
await getPool().query(
`UPDATE org_billing_config
SET stripe_default_payment_method_id = NULL,
stripe_pm_brand = NULL,
stripe_pm_last4 = NULL,
stripe_pm_exp_month = NULL,
stripe_pm_exp_year = NULL,
updated_at = now()
WHERE zitadel_org_id = $1`,
[zitadelOrgId]
);
}
/**
* Toggle the auto_charge_enabled flag. Used by the customer's
* "Disable auto-pay / Enable auto-pay" button in /settings/billing
* and (Phase 9b) the admin override on /admin/billing/orgs.
*/
export async function setAutoChargeEnabled(
zitadelOrgId: string,
enabled: boolean
): Promise<void> {
await getPool().query(
`INSERT INTO org_billing_config (zitadel_org_id, auto_charge_enabled, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (zitadel_org_id) DO UPDATE SET
auto_charge_enabled = EXCLUDED.auto_charge_enabled,
updated_at = now()`,
[zitadelOrgId, enabled]
);
}
/**
* Look up the org id for a given Stripe customer id — used by the
* webhook when a checkout.session.completed in setup mode arrives
* and we need to find which org to save the card against. The
* customer id is the join key Stripe gives us in the session.
*/
export async function getOrgIdByStripeCustomerId(
stripeCustomerId: string
): Promise<string | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT zitadel_org_id FROM org_billing_config
WHERE stripe_customer_id = $1
LIMIT 1`,
[stripeCustomerId]
);
return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null;
}
// ---------------------------------------------------------------------------
// Phase 9b — tenant order with setup-fee charge
// ---------------------------------------------------------------------------
/**
* Phase 9b: invoked by the Stripe webhook when the setup-fee
* Checkout for a tenant order completes. Atomically:
* - flips the request status from 'pending_payment' → 'pending'
* (admin queue now sees it)
* - sets tenant_name to the derived value (so monthly cron's
* setup-fee dedup works)
* - links the paid invoice via setup_invoice_id (so admin reject
* can refund it via the existing refund flow)
*
* Idempotent on the request side: if the webhook re-fires after
* the row already has status='pending', the UPDATE is a no-op
* (same values). On the rare case of webhook retry happening after
* admin already approved/rejected, the WHERE clause guards against
* regressing the status.
*/
export async function linkTenantRequestSetupPayment(params: {
requestId: string;
tenantName: string;
setupInvoiceId: string;
}): Promise<boolean> {
const result = await getPool().query(
`UPDATE tenant_requests
SET status = 'pending',
tenant_name = $2,
setup_invoice_id = $3,
updated_at = now()
WHERE id = $1
AND status = 'pending_payment'
RETURNING id`,
[params.requestId, params.tenantName, params.setupInvoiceId]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Look up a tenant request by id without restricting by status —
* used by the webhook + reject handler. Caller is responsible for
* any role-gating; this is a pure read.
*/
export async function getTenantRequestForSetupFlow(
requestId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests WHERE id = $1`,
[requestId]
);
return result.rows.length > 0
? mapRow(result.rows[0])
: null;
}
/**
* Insert a tenant request row in the 'pending_payment' status —
* used at order time, before the Stripe Checkout completes. Once
* payment succeeds the webhook flips it to 'pending' via
* linkTenantRequestSetupPayment.
*
* tenant_name stays NULL throughout pending_payment so the unique
* partial index uniq_tenant_requests_tenant_name_provision
* (WHERE tenant_name IS NOT NULL) doesn't block retries from
* abandoned Checkout sessions. The derived tenant_name is computed
* by the caller from the inserted row's id and stored only at
* webhook time.
*/
export async function createTenantRequestPendingPayment(params: {
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
instanceName?: string | null;
contactName: string;
contactEmail: string;
agentName: string;
soulMd?: string;
agentsMd?: string | null;
packages: string[];
billingAddress: BillingAddress;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
isPersonal: boolean;
channelUsers?: Record<string, string[]>;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id,
company_name, instance_name, contact_name, contact_email,
agent_name, soul_md, agents_md, packages,
billing_address, billing_notes,
encrypted_secrets, is_personal,
channel_users,
status, request_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
$13, $14, $15::jsonb, 'pending_payment', 'provision'
)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd ?? null,
params.agentsMd ?? null,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes ?? null,
params.encryptedSecrets ?? null,
params.isPersonal,
JSON.stringify(params.channelUsers ?? {}),
]
);
return mapRow(result.rows[0]);
}
/**
* Delete a pending_payment row — used when admin or system needs
* to clean up an abandoned order (e.g. Checkout session expired
* before the customer completed payment). Guarded: only deletes
* if status is still 'pending_payment' so we never accidentally
* delete a request that admin has already approved.
*
* Also nulls any setup_invoice_id reference before deleting so we
* don't leave dangling FK refs (we don't have ON DELETE behavior
* defined on the column).
*/
export async function deletePendingPaymentRequest(
requestId: string
): Promise<boolean> {
const result = await getPool().query(
`DELETE FROM tenant_requests
WHERE id = $1 AND status = 'pending_payment'
RETURNING id`,
[requestId]
);
return (result.rowCount ?? 0) > 0;
}

View File

@@ -1321,3 +1321,142 @@ export async function sendCreditNoteEmail(params: {
console.error("Failed to send credit note email:", err); console.error("Failed to send credit note email:", err);
} }
} }
// ---------------------------------------------------------------------------
// Phase 9b-2 — auto-charge failure notice
// ---------------------------------------------------------------------------
/**
* Sent when an off-session auto-charge attempt fails for an issued
* invoice (card declined, expired, 3DS required, etc.). Customer
* receives this in their billing-snapshot locale. Contains:
* - Invoice number + amount + due date
* - Failure reason (a short human-readable string from Stripe)
* - Manual-pay link to /billing/<invoiceNumber> where they can
* run the regular Pay-by-Card flow (which uses
* setup_future_usage to also refresh the saved card)
*
* Critical: the failure reason from Stripe can contain sensitive
* details (card BIN, country, etc.). We pass a sanitized short
* string from the caller — never the full raw error.
*/
export async function sendAutoChargeFailedEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
/**
* Short, customer-safe reason. e.g. "Your card was declined."
* or "Your card has expired." Caller maps Stripe error codes to
* these strings; we never pass raw API error messages.
*/
reasonForCustomer: string;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const dueFmt = params.dueAt.slice(0, 10);
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
const subjectsByLocale: Record<typeof L, string> = {
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
};
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: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
};
const reasonLabel: Record<typeof L, string> = {
en: "Reason given by the card network",
de: "Vom Kartennetzwerk gemeldeter Grund",
fr: "Motif communiqué par le réseau de carte",
it: "Motivo comunicato dal circuito",
};
const actionLineByLocale: Record<typeof L, string> = {
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
};
const labels: Record<typeof L, Record<string, string>> = {
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", 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 safeReason = escapeHtml(params.reasonForCustomer);
const safeIntro = escapeHtml(introByLocale[L]);
const safeAction = escapeHtml(actionLineByLocale[L]);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: subjectsByLocale[L],
text: [
greetingsByLocale[L],
"",
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
"",
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
"",
actionLineByLocale[L],
"",
`${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: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
<p>${escapeHtml(greetingsByLocale[L])}</p>
<p>${safeIntro}</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.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
</table>
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
</div>
<p style="font-size:14px;">${safeAction}</p>
<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>
<p style="color:#888; font-size:12px; margin-top:24px;">
${l.signoff},<br />${l.brand}
</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send auto-charge-failed email:", err);
}
}

View File

@@ -76,6 +76,29 @@ export interface PackageDef {
* admin does the manual work, then approves. * admin does the manual work, then approves.
*/ */
requiresManualSetup?: boolean; requiresManualSetup?: boolean;
/**
* Phase 9b: when true, the wizard visually highlights this package
* as recommended (a badge + accent border) without pre-selecting
* it. Used for the Threema channel — we want customers to choose
* Threema as their messaging surface when possible, but the choice
* stays opt-in.
*/
recommended?: boolean;
/**
* Phase 9b: when true, the onboarding wizard collects the
* customer's own user id for this channel (e.g. their Telegram
* numeric id, their Threema ID) at request time. The collected
* id is forwarded with the tenant request, stored on the row,
* and applied on admin approval:
* - spec.channelUsers[<channel>] gets the id seeded so the
* operator's first reconcile already has it
* - for Threema specifically, the approve handler additionally
* calls the relay's createRoute() so inbound messages from
* that id reach the new tenant
* Customers can add more ids later via the channel-users page.
* Help copy and label come from channelUsers.<id>IdHelp.
*/
collectsChannelUserId?: boolean;
} }
export const PACKAGE_CATALOG: PackageDef[] = [ export const PACKAGE_CATALOG: PackageDef[] = [
@@ -129,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.telegram.instructions", instructionsKey: "packages.telegram.instructions",
disclaimerKey: "packages.telegram.disclaimer", disclaimerKey: "packages.telegram.disclaimer",
category: "channel", category: "channel",
collectsChannelUserId: true,
}, },
{ {
id: "discord", id: "discord",
@@ -158,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.discord.instructions", instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer", disclaimerKey: "packages.discord.disclaimer",
category: "channel", category: "channel",
collectsChannelUserId: true,
}, },
{ {
id: "threema", id: "threema",
@@ -173,6 +198,8 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.threema.instructions", instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer", disclaimerKey: "packages.threema.disclaimer",
category: "channel", category: "channel",
recommended: true,
collectsChannelUserId: true,
}, },
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -231,7 +258,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
}, },
{ {
id: "gog", id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)", name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description", descriptionKey: "packages.gog.description",
requiresSecrets: true, requiresSecrets: true,
@@ -334,9 +360,11 @@ export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
* audio spend on every inbound voice note (Whisper STT) and every * audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps * outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels. * cost predictable for tenants who don't intend to use voice channels.
*
* Phase 9b revision: nothing is pre-enabled. New tenants start with a
* blank slate — the customer opts into exactly what they want. The
* Threema channel is flagged `recommended` (see PACKAGE_CATALOG) so
* the wizard highlights it, since we want customers to use Threema as
* their channel when possible — but it's still opt-in, not auto-on.
*/ */
export const DEFAULT_PACKAGE_IDS: string[] = [ export const DEFAULT_PACKAGE_IDS: string[] = [];
"core-heartbeat",
"core-cron",
"core-active-memory",
];

View File

@@ -250,6 +250,15 @@ export async function createCheckoutSessionForInvoice(params: {
// since Stripe will prepend the merchant name from the // since Stripe will prepend the merchant name from the
// account anyway. Keep it short and recognisable. // account anyway. Keep it short and recognisable.
description: `Invoice ${invoice.invoiceNumber}`, description: `Invoice ${invoice.invoiceNumber}`,
// Phase 9b-2: every manual Pay-by-Card refreshes the org's
// saved PaymentMethod. The webhook (payment-mode handler) is
// already wired to read setup_future_usage and persist the
// resulting PM's display fields against the org. Net effect:
// a customer whose auto-charge failed because their card
// expired pays manually once → fresh card is now saved →
// next month auto-charges work again. No separate "update
// card" step needed.
setup_future_usage: "off_session",
}, },
success_url: successUrl, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
@@ -318,3 +327,326 @@ export async function createInvoiceRefund(params: {
status: refund.status ?? "unknown", status: refund.status ?? "unknown",
}; };
} }
// ---------------------------------------------------------------------------
// Phase 9 — saved cards (SetupIntent / Checkout setup mode)
// ---------------------------------------------------------------------------
/**
* Create a Checkout session in setup mode — Stripe collects card
* details and authorizes them for off-session future charges,
* without charging anything now. On success, Stripe attaches the
* resulting PaymentMethod to the customer object and fires
* `checkout.session.completed` with mode='setup'.
*
* The webhook handler reads the session's setup_intent, extracts
* the payment_method id, and persists the display fields
* (brand/last4/exp) via setSavedPaymentMethod. From that moment
* on, the customer has auto-charge wired up.
*
* Re-running this against a customer who already has a saved card
* is supported — Stripe attaches the new PaymentMethod and the
* webhook overwrites the old one in our DB. That's how "Update
* card" works.
*/
export async function createSetupCheckoutSession(params: {
customerId: string;
baseUrl: string;
locale?: "de" | "en" | "fr" | "it";
/**
* Where to redirect after the customer completes / cancels the
* setup. Defaults to /settings/billing — the natural landing
* spot after saving a card.
*/
returnPath?: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { customerId, baseUrl, locale } = params;
const returnPath = params.returnPath ?? "/settings/billing";
const stripeLocale =
locale === "de"
? ("de" as const)
: locale === "fr"
? ("fr" as const)
: locale === "it"
? ("it" as const)
: locale === "en"
? ("en" as const)
: ("auto" as const);
const successUrl = `${baseUrl}${returnPath}?card_setup=success&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}${returnPath}?card_setup=cancelled`;
const session = await stripe.checkout.sessions.create({
mode: "setup",
customer: customerId,
locale: stripeLocale,
payment_method_types: ["card"],
success_url: successUrl,
cancel_url: cancelUrl,
// Stripe attaches the resulting PaymentMethod to the customer
// and the webhook fires with session.setup_intent populated.
// No extra setup_intent_data needed for the basic flow.
});
if (!session.url) {
throw new Error(
`Stripe returned a setup session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}
/**
* Detach a PaymentMethod from its customer. Used when the customer
* clicks "Remove card" — the PM is no longer usable for charges
* once detached. The Stripe Customer object survives (so future
* charges can still attach a new card to the same customer).
*
* Stripe permits detaching a PM that's already detached as a
* no-op; safe to retry.
*/
export async function detachPaymentMethod(
paymentMethodId: string
): Promise<void> {
const stripe = getStripeClient();
try {
await stripe.paymentMethods.detach(paymentMethodId);
} catch (e: any) {
// Stripe returns 404 if the PM is already detached or doesn't
// exist — treat as success since the intended end-state ("not
// attached") is already reached. Re-throw anything else.
if (e?.statusCode === 404) return;
throw e;
}
}
/**
* Fetch the display fields for a PaymentMethod (brand, last4,
* exp). Used by the webhook to read out what to persist after a
* setup session completes; the session itself only carries the
* PM id, not the card details.
*/
export async function getPaymentMethodDisplay(
paymentMethodId: string
): Promise<{
brand: string | null;
last4: string | null;
expMonth: number | null;
expYear: number | null;
}> {
const stripe = getStripeClient();
const pm = await stripe.paymentMethods.retrieve(paymentMethodId);
// The card object is only present when type='card'. We don't
// anticipate non-card PMs in this codebase yet, but defensive
// null-handling avoids crashing if Stripe surfaces something
// unexpected (Apple Pay, link, etc. — all of which still
// resolve to a card under the hood).
const card = (pm as any).card;
if (!card) {
return { brand: null, last4: null, expMonth: null, expYear: null };
}
return {
brand: card.brand ?? null,
last4: card.last4 ?? null,
expMonth: typeof card.exp_month === "number" ? card.exp_month : null,
expYear: typeof card.exp_year === "number" ? card.exp_year : null,
};
}
// ---------------------------------------------------------------------------
// Phase 9b — order-time setup-fee Checkout
// ---------------------------------------------------------------------------
/**
* Create a Stripe Checkout session that charges the setup-fee
* invoice immediately AND saves/refreshes the customer's
* PaymentMethod for future off-session use (recurring monthly
* charges).
*
* Same `mode: 'payment'` as the regular pay-invoice Checkout —
* the difference is:
* - metadata.flow = 'setup_fee' so the webhook knows to flip
* the tenant_request row from 'pending_payment' to 'pending'
* and link the invoice to it
* - metadata.tenant_request_id is the row to update
* - payment_intent_data.setup_future_usage = 'off_session' so
* the resulting PaymentMethod gets saved against the customer.
* Phase 9b-2's recurring auto-charge reads that PM id
*
* Success URL routes to /dashboard?ordered=1 (vs. the regular
* pay flow which lands on /billing/<invoiceNumber>). Cancel
* routes to /onboarding?cancelled=1 so the customer can retry.
*/
export async function createSetupFeeCheckoutSession(params: {
invoice: Invoice;
customerId: string;
baseUrl: string;
tenantRequestId: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { invoice, customerId, baseUrl, tenantRequestId } = params;
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}/dashboard?ordered=1&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/onboarding?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: `Setup fee — ${invoice.invoiceNumber}`,
description: `PieCed IT — tenant setup`,
},
},
},
],
payment_intent_data: {
// Save the resulting PaymentMethod against the customer for
// future off-session use (Phase 9b-2 recurring charges).
setup_future_usage: "off_session",
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
},
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
// Phase 9b discriminators — webhook reads these to do the
// tenant_request linkage on top of the regular invoice-paid
// flow.
flow: "setup_fee",
tenant_request_id: tenantRequestId,
},
success_url: successUrl,
cancel_url: cancelUrl,
});
if (!session.url) {
throw new Error(
`Stripe returned a setup-fee session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}
// ---------------------------------------------------------------------------
// Phase 9b-2 — off-session auto-charge for issued invoices
// ---------------------------------------------------------------------------
/**
* Attempt to charge an invoice off-session against the customer's
* saved PaymentMethod. Used by chargeInvoiceIfPossible() from
* generateInvoice (monthly) and issueCustomInvoiceDraft (admin
* custom).
*
* Stripe semantics with `off_session: true, confirm: true`:
* - On success: PaymentIntent.status = 'succeeded', card was
* charged. Returns 'succeeded'.
* - On 3DS required: PaymentIntent.status = 'requires_action'.
* We can't complete this off-session. Customer must pay
* manually via Checkout (which handles 3DS in-browser).
* Returns 'requires_action'.
* - On hard decline: thrown StripeCardError, code = 'card_declined'
* or 'insufficient_funds' etc. Returns 'declined' with the
* error code.
* - On expired card or other recoverable issue: thrown
* StripeCardError. Returns 'declined' with the code.
*
* The receipt_email is set to the org's billing email so Stripe
* sends the customer an automated receipt on success — we don't
* need to send our own "you've been charged" email.
*/
export type ChargeOutcome =
| { status: "succeeded"; paymentIntentId: string }
| { status: "requires_action"; paymentIntentId: string; reason: string }
| { status: "declined"; reason: string; code?: string };
export async function chargeInvoiceOffSession(params: {
invoice: Invoice;
customerId: string;
paymentMethodId: string;
/**
* If set, Stripe emails an automated receipt here on successful
* capture. We use the org's billing snapshot email so the receipt
* goes to the same address as the issued / failed emails.
*/
receiptEmail?: string | null;
}): Promise<ChargeOutcome> {
const stripe = getStripeClient();
const { invoice, customerId, paymentMethodId, receiptEmail } = params;
try {
const pi = await stripe.paymentIntents.create({
amount: chfToRappen(invoice.totalChf),
currency: "chf",
customer: customerId,
payment_method: paymentMethodId,
off_session: true,
confirm: true,
description: `Invoice ${invoice.invoiceNumber}`,
receipt_email: receiptEmail ?? undefined,
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
flow: "auto_charge",
},
});
if (pi.status === "succeeded") {
return { status: "succeeded", paymentIntentId: pi.id };
}
if (pi.status === "requires_action") {
return {
status: "requires_action",
paymentIntentId: pi.id,
reason: "Authentication required (3DS). Customer must pay via Checkout.",
};
}
// Any other non-succeeded status (rare with off_session+confirm)
// is treated as a failure for our purposes.
return {
status: "declined",
reason: `Unexpected PaymentIntent status: ${pi.status}`,
};
} catch (e: any) {
// Stripe's off-session declines surface as a StripeCardError
// with the PI on e.payment_intent. The 'code' (e.g.
// 'card_declined', 'expired_card', 'authentication_required')
// is the most actionable signal; e.message is human-readable.
const code: string | undefined = e?.code ?? e?.raw?.code;
const message: string =
e?.message ?? e?.raw?.message ?? "Card was declined.";
// authentication_required is technically a "decline" from the
// off-session path even though it could succeed on-session.
// Surface it distinctly so the caller can tell the customer to
// go pay manually (which will use Checkout + handle 3DS).
if (code === "authentication_required") {
const piId = e?.payment_intent?.id ?? "";
return {
status: "requires_action",
paymentIntentId: piId,
reason: "Authentication required (3DS). Customer must pay via Checkout.",
};
}
return { status: "declined", reason: message, code };
}
}

View File

@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
packageSecrets: z packageSecrets: z
.record(z.string(), z.record(z.string(), z.string())) .record(z.string(), z.record(z.string(), z.string()))
.optional(), .optional(),
// Phase 9b: per-channel initial user ids collected during
// onboarding. Map of channel package id → list of user ids the
// customer wants to authorize. Applied at admin approval time.
channelUsers: z
.record(z.string(), z.array(z.string().trim().min(1).max(200)))
.optional(),
billingAddress: billingAddressSchema.optional(), billingAddress: billingAddressSchema.optional(),
billingNotes: z.string().max(2_000).optional(), billingNotes: z.string().max(2_000).optional(),
}); });

View File

@@ -122,7 +122,27 @@
"billingVatNumber": "MWST-Nummer", "billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.", "billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.", "billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd." "reviewContactPersonPrefix": "z.Hd.",
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um Ihre Zahlungsdetails einzugeben und die einmalige Einrichtungsgebühr zu bezahlen. Ihre Karte wird automatisch für die zukünftige monatliche Abrechnung gespeichert. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
"setupFeePlusVat": "+ MwSt.",
"optional": "optional",
"yourChannelIdLabel": {
"telegram": "Ihre Telegram-Benutzer-ID",
"discord": "Ihre Discord-Benutzer-ID",
"threema": "Ihre Threema-ID"
},
"yourChannelIdPlaceholder": {
"telegram": "z.B. 1234567890",
"discord": "z.B. 234567890123456789",
"threema": "z.B. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"threema": "Die 8-stellige ID, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt wird. Nach der Freigabe können Sie direkt von diesem Threema-Account aus mit dem Assistenten chatten. Weitere autorisierte IDs können Sie später auf der Mandantenseite hinzufügen."
}
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -319,7 +339,12 @@
"activationRejected": "Abgelehnt", "activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen", "tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert", "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." "credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben.",
"recommended": "Empfohlen",
"threemaBotIdHeading": "Bot-Threema-ID",
"threemaBotIdHint": "Sobald Ihr Mandant freigegeben ist, scannen Sie diesen QR-Code mit Threema, um den Assistenten zu Ihren Kontakten hinzuzufügen. Der QR-Code ist für jeden PieCed-Mandanten identisch — Sie können ihn schon jetzt speichern.",
"showInfo": "Info",
"showInfoTitle": "Setup-Info erneut anzeigen"
}, },
"admin": { "admin": {
"title": "Plattform-Admin", "title": "Plattform-Admin",
@@ -501,7 +526,7 @@
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.", "notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern", "createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…", "saving": "Wird gespeichert…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.", "missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
@@ -509,7 +534,27 @@
"fullNameLabel": "Vor- und Nachname", "fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.", "subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)", "contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen." "contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen.",
"savedCardHeading": "Hinterlegte Karte",
"savedCardEmptyBody": "Hinterlegen Sie eine Karte für die automatische Bezahlung von Rechnungen. Ihre Kartendaten werden sicher bei Stripe gespeichert — wir sehen nur Marke, letzte vier Ziffern und Ablaufdatum.",
"savedCardSetupBtn": "Auto-Zahlung einrichten",
"savedCardRedirecting": "Weiterleitung…",
"savedCardUpdateBtn": "Karte aktualisieren",
"savedCardRemoveBtn": "Karte entfernen",
"savedCardRemoving": "Entfernen…",
"savedCardRemoveConfirm": "Diese Karte entfernen? Sie müssen die Auto-Zahlung erneut einrichten, damit zukünftige Rechnungen automatisch belastet werden.",
"savedCardBrandUnknown": "Karte",
"savedCardExpires": "läuft ab {date}",
"savedCardAutoChargeOn": "Auto-Zahlung aktiv",
"savedCardAutoChargeOff": "Auto-Zahlung inaktiv",
"savedCardDisableAutoChargeBtn": "Auto-Zahlung deaktivieren",
"savedCardEnableAutoChargeBtn": "Auto-Zahlung aktivieren",
"savedCardPayByInvoiceNote": "Ihr Konto ist auf Banküberweisung eingestellt; die hinterlegte Karte wird nicht für automatische Abbuchungen verwendet. Wenden Sie sich an den Support, wenn Sie wieder per Karte bezahlen möchten.",
"savedCardBankTransferHint": "Banküberweisung ist auf Anfrage ebenfalls möglich.",
"savedCardBankTransferLink": "Kontaktieren Sie uns dafür.",
"savedCardAutoPayRequiredHeading": "Auto-Zahlung ist erforderlich",
"savedCardAutoPayRequiredBody": "PieCed IT arbeitet mit automatischer Kartenzahlung. Wir behalten uns das Recht vor, Tenants bis zur Begleichung offener Rechnungen zu sperren, falls die automatische Abrechnung fehlschlägt.",
"savedCardAutoPayDisabledNote": "Auto-Zahlung ist derzeit deaktiviert. Zukünftige Rechnungen müssen manuell beglichen werden — bei Nichtbezahlung behalten wir uns das Recht vor, die zugehörigen Tenants zu sperren."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -764,7 +809,21 @@
"editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.", "editorIssueConfirm": "Rechnung jetzt ausstellen? Eine Rechnungsnummer wird zugewiesen, das PDF wird dem Kunden zugesendet und dieser Entwurf wird entfernt.",
"editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.", "editorDeleteConfirm": "Diesen Entwurf verwerfen? Kann nicht rückgängig gemacht werden.",
"previewing": "Wird geöffnet…", "previewing": "Wird geöffnet…",
"issuing": "Wird ausgestellt…" "issuing": "Wird ausgestellt…",
"orgsTitle": "Kunden-Abrechnung",
"orgsDesc": "Zahlungsart + Auto-Zahlung pro Kunde",
"orgsPageTitle": "Kunden-Abrechnungsmodi",
"orgsPageSubtitle": "Überschreibung der Zahlungsart für einzelne Kunden. Zahlung per Rechnung ersetzt die automatische Kartenabbuchung durch manuelle Banküberweisung; das Pausieren der Auto-Zahlung behält die hinterlegte Karte, stoppt aber Abbuchungsversuche (nützlich bei Streitfällen).",
"orgsEmpty": "Noch keine Kunden-Organisationen.",
"orgsColCustomer": "Kunde",
"orgsColCard": "Hinterlegte Karte",
"orgsColPayByInvoice": "Zahlung per Banküberweisung",
"orgsColAutoCharge": "Auto-Zahlung",
"orgsNoSavedCard": "keine",
"orgsPayByInvoiceOn": "ein",
"orgsPayByInvoiceOff": "aus",
"orgsAutoChargeOn": "ein",
"orgsAutoChargeOff": "aus"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Aktivierungskosten bestätigen", "title": "Aktivierungskosten bestätigen",

View File

@@ -122,7 +122,27 @@
"billingVatNumber": "VAT number", "billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.", "billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.", "billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:" "reviewContactPersonPrefix": "Attn:",
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to enter your payment details and pay the one-time setup fee. Your card is saved automatically for future monthly billing. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date.",
"setupFeeAmountLabel": "One-time setup fee",
"setupFeePlusVat": "+ VAT",
"optional": "optional",
"yourChannelIdLabel": {
"telegram": "Your Telegram user ID",
"discord": "Your Discord user ID",
"threema": "Your Threema ID"
},
"yourChannelIdPlaceholder": {
"telegram": "e.g. 1234567890",
"discord": "e.g. 234567890123456789",
"threema": "e.g. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
"threema": "The 8-character ID shown in your Threema app under Settings → My Threema ID. Once approved, you can chat with the assistant directly from this Threema account. You can add more authorized IDs later from the tenant page."
}
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -319,7 +339,12 @@
"activationRejected": "Rejected", "activationRejected": "Rejected",
"tryAgain": "Try again", "tryAgain": "Try again",
"credentialsSaved": "credentials saved", "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." "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.",
"recommended": "Recommended",
"threemaBotIdHeading": "Bot Threema ID",
"threemaBotIdHint": "Once your tenant is approved, scan this QR with Threema to add the assistant to your contacts. The QR is the same for every PieCed tenant — you can save it now.",
"showInfo": "Info",
"showInfoTitle": "Show setup info again"
}, },
"admin": { "admin": {
"title": "Platform Admin", "title": "Platform Admin",
@@ -509,7 +534,27 @@
"fullNameLabel": "Full name", "fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.", "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)", "contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations." "contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations.",
"savedCardHeading": "Saved card",
"savedCardEmptyBody": "Save a card for automatic invoice payments. Your card details are stored securely by Stripe — we only see the brand, last four digits, and expiration.",
"savedCardSetupBtn": "Set up auto-pay",
"savedCardRedirecting": "Redirecting…",
"savedCardUpdateBtn": "Update card",
"savedCardRemoveBtn": "Remove card",
"savedCardRemoving": "Removing…",
"savedCardRemoveConfirm": "Remove this card? You'll need to set up auto-pay again for future invoices to charge automatically.",
"savedCardBrandUnknown": "Card",
"savedCardExpires": "expires {date}",
"savedCardAutoChargeOn": "Auto-pay on",
"savedCardAutoChargeOff": "Auto-pay off",
"savedCardDisableAutoChargeBtn": "Disable auto-pay",
"savedCardEnableAutoChargeBtn": "Enable auto-pay",
"savedCardPayByInvoiceNote": "Your account is set to pay by bank transfer; the saved card is not used for automatic charges. Contact support if you'd like to switch back to card payment.",
"savedCardBankTransferHint": "Bank transfer is also available on request.",
"savedCardBankTransferLink": "Contact us to arrange.",
"savedCardAutoPayRequiredHeading": "Auto-pay is required",
"savedCardAutoPayRequiredBody": "PieCed IT operates on automatic card payment. We reserve the right to suspend tenants until outstanding invoices are paid if automatic billing fails.",
"savedCardAutoPayDisabledNote": "Auto-pay is currently disabled. Future invoices will need to be paid manually — if they go unpaid we reserve the right to suspend the tenants associated with this account."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -764,7 +809,21 @@
"editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.", "editorIssueConfirm": "Issue this invoice now? An invoice number will be allocated, the PDF will be sent to the customer, and this draft will be removed.",
"editorDeleteConfirm": "Discard this draft? This cannot be undone.", "editorDeleteConfirm": "Discard this draft? This cannot be undone.",
"previewing": "Opening…", "previewing": "Opening…",
"issuing": "Issuing…" "issuing": "Issuing…",
"orgsTitle": "Customer billing",
"orgsDesc": "Payment mode + auto-charge per customer",
"orgsPageTitle": "Customer billing modes",
"orgsPageSubtitle": "Override payment mode for individual customers. Pay-by-invoice replaces card auto-charge with manual bank transfer; pausing auto-charge keeps the saved card on file but stops attempting charges (useful during disputes).",
"orgsEmpty": "No customer orgs yet.",
"orgsColCustomer": "Customer",
"orgsColCard": "Saved card",
"orgsColPayByInvoice": "Pay by bank transfer",
"orgsColAutoCharge": "Auto-charge",
"orgsNoSavedCard": "none",
"orgsPayByInvoiceOn": "on",
"orgsPayByInvoiceOff": "off",
"orgsAutoChargeOn": "on",
"orgsAutoChargeOff": "off"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirm activation cost", "title": "Confirm activation cost",

View File

@@ -122,7 +122,27 @@
"billingVatNumber": "Numéro de TVA", "billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.", "billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.", "billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de" "reviewContactPersonPrefix": "À l'attention de",
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour saisir vos coordonnées de paiement et régler les frais d'activation uniques. Votre carte est enregistrée automatiquement pour la facturation mensuelle future. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation.",
"setupFeeAmountLabel": "Frais d'activation uniques",
"setupFeePlusVat": "+ TVA",
"optional": "facultatif",
"yourChannelIdLabel": {
"telegram": "Votre ID utilisateur Telegram",
"discord": "Votre ID utilisateur Discord",
"threema": "Votre ID Threema"
},
"yourChannelIdPlaceholder": {
"telegram": "ex. 1234567890",
"discord": "ex. 234567890123456789",
"threema": "ex. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"threema": "L'identifiant à 8 caractères affiché dans votre app Threema sous Paramètres → Mon ID Threema. Une fois approuvé, vous pourrez chatter avec l'assistant directement depuis ce compte Threema. Vous pourrez ajouter d'autres ID autorisés plus tard depuis la page du tenant."
}
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -319,7 +339,12 @@
"activationRejected": "Refusée", "activationRejected": "Refusée",
"tryAgain": "Réessayer", "tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés", "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." "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.",
"recommended": "Recommandé",
"threemaBotIdHeading": "ID Threema du bot",
"threemaBotIdHint": "Une fois votre tenant approuvé, scannez ce QR avec Threema pour ajouter l'assistant à vos contacts. Le QR est identique pour chaque tenant PieCed — vous pouvez l'enregistrer dès maintenant.",
"showInfo": "Info",
"showInfoTitle": "Réafficher les infos de configuration"
}, },
"admin": { "admin": {
"title": "Admin plateforme", "title": "Admin plateforme",
@@ -509,7 +534,27 @@
"fullNameLabel": "Nom et prénom", "fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.", "subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)", "contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations." "contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations.",
"savedCardHeading": "Carte enregistrée",
"savedCardEmptyBody": "Enregistrez une carte pour le paiement automatique des factures. Les données de votre carte sont stockées de manière sécurisée par Stripe — nous ne voyons que la marque, les quatre derniers chiffres et la date d'expiration.",
"savedCardSetupBtn": "Configurer le paiement automatique",
"savedCardRedirecting": "Redirection…",
"savedCardUpdateBtn": "Mettre à jour la carte",
"savedCardRemoveBtn": "Supprimer la carte",
"savedCardRemoving": "Suppression…",
"savedCardRemoveConfirm": "Supprimer cette carte ? Vous devrez reconfigurer le paiement automatique pour que les futures factures soient prélevées automatiquement.",
"savedCardBrandUnknown": "Carte",
"savedCardExpires": "expire {date}",
"savedCardAutoChargeOn": "Paiement auto. actif",
"savedCardAutoChargeOff": "Paiement auto. inactif",
"savedCardDisableAutoChargeBtn": "Désactiver le paiement automatique",
"savedCardEnableAutoChargeBtn": "Activer le paiement automatique",
"savedCardPayByInvoiceNote": "Votre compte est configuré pour le paiement par virement ; la carte enregistrée n'est pas utilisée pour les prélèvements automatiques. Contactez le support si vous souhaitez revenir au paiement par carte.",
"savedCardBankTransferHint": "Le paiement par virement est également possible sur demande.",
"savedCardBankTransferLink": "Contactez-nous pour l'organiser.",
"savedCardAutoPayRequiredHeading": "Le paiement automatique est requis",
"savedCardAutoPayRequiredBody": "PieCed IT fonctionne sur la base d'un paiement automatique par carte. Nous nous réservons le droit de suspendre les tenants jusqu'au règlement des factures impayées si la facturation automatique échoue.",
"savedCardAutoPayDisabledNote": "Le paiement automatique est actuellement désactivé. Les factures futures devront être réglées manuellement — en cas de non-paiement, nous nous réservons le droit de suspendre les tenants associés à ce compte."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -764,7 +809,21 @@
"editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.", "editorIssueConfirm": "Émettre cette facture maintenant ? Un numéro de facture sera attribué, le PDF sera envoyé au client et ce brouillon sera supprimé.",
"editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.", "editorDeleteConfirm": "Supprimer ce brouillon ? Cette action est irréversible.",
"previewing": "Ouverture…", "previewing": "Ouverture…",
"issuing": "Émission…" "issuing": "Émission…",
"orgsTitle": "Facturation client",
"orgsDesc": "Mode de paiement + paiement auto. par client",
"orgsPageTitle": "Modes de facturation client",
"orgsPageSubtitle": "Surcharge du mode de paiement pour les clients individuels. Le paiement par virement remplace le prélèvement automatique par carte ; la pause du paiement automatique conserve la carte enregistrée mais cesse les tentatives de prélèvement (utile en cas de litige).",
"orgsEmpty": "Aucun client pour le moment.",
"orgsColCustomer": "Client",
"orgsColCard": "Carte enregistrée",
"orgsColPayByInvoice": "Paiement par virement",
"orgsColAutoCharge": "Paiement automatique",
"orgsNoSavedCard": "aucune",
"orgsPayByInvoiceOn": "actif",
"orgsPayByInvoiceOff": "inactif",
"orgsAutoChargeOn": "actif",
"orgsAutoChargeOff": "inactif"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirmer le coût d'activation", "title": "Confirmer le coût d'activation",

View File

@@ -122,7 +122,27 @@
"billingVatNumber": "Partita IVA", "billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.", "billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.", "billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a." "reviewContactPersonPrefix": "c.a.",
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per inserire i dati di pagamento e pagare le spese di attivazione una tantum. La sua carta viene salvata automaticamente per la fatturazione mensile futura. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
"setupFeeAmountLabel": "Spese di attivazione una tantum",
"setupFeePlusVat": "+ IVA",
"optional": "facoltativo",
"yourChannelIdLabel": {
"telegram": "Il suo ID utente Telegram",
"discord": "Il suo ID utente Discord",
"threema": "Il suo ID Threema"
},
"yourChannelIdPlaceholder": {
"telegram": "es. 1234567890",
"discord": "es. 234567890123456789",
"threema": "es. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"threema": "L'ID di 8 caratteri mostrato nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato, potrà chattare con l'assistente direttamente da questo account Threema. Potrà aggiungere altri ID autorizzati in seguito dalla pagina del tenant."
}
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -319,7 +339,12 @@
"activationRejected": "Rifiutata", "activationRejected": "Rifiutata",
"tryAgain": "Riprova", "tryAgain": "Riprova",
"credentialsSaved": "credenziali salvate", "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." "credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle.",
"recommended": "Consigliato",
"threemaBotIdHeading": "ID Threema del bot",
"threemaBotIdHint": "Una volta approvato il suo tenant, scansioni questo QR con Threema per aggiungere l'assistente ai suoi contatti. Il QR è identico per ogni tenant PieCed — può salvarlo già adesso.",
"showInfo": "Info",
"showInfoTitle": "Mostra di nuovo le info di setup"
}, },
"admin": { "admin": {
"title": "Admin piattaforma", "title": "Admin piattaforma",
@@ -509,7 +534,27 @@
"fullNameLabel": "Nome e cognome", "fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.", "subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)", "contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni." "contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni.",
"savedCardHeading": "Carta salvata",
"savedCardEmptyBody": "Salvi una carta per il pagamento automatico delle fatture. I dati della sua carta sono memorizzati in modo sicuro da Stripe — vediamo solo la marca, le ultime quattro cifre e la scadenza.",
"savedCardSetupBtn": "Configura pagamento automatico",
"savedCardRedirecting": "Reindirizzamento…",
"savedCardUpdateBtn": "Aggiorna carta",
"savedCardRemoveBtn": "Rimuovi carta",
"savedCardRemoving": "Rimozione…",
"savedCardRemoveConfirm": "Rimuovere questa carta? Dovrà riconfigurare il pagamento automatico affinché le future fatture vengano addebitate automaticamente.",
"savedCardBrandUnknown": "Carta",
"savedCardExpires": "scade {date}",
"savedCardAutoChargeOn": "Pagamento auto. attivo",
"savedCardAutoChargeOff": "Pagamento auto. disattivo",
"savedCardDisableAutoChargeBtn": "Disattiva pagamento automatico",
"savedCardEnableAutoChargeBtn": "Attiva pagamento automatico",
"savedCardPayByInvoiceNote": "Il suo account è impostato per il pagamento tramite bonifico; la carta salvata non viene utilizzata per gli addebiti automatici. Contatti l'assistenza se desidera tornare al pagamento con carta.",
"savedCardBankTransferHint": "Il pagamento tramite bonifico è disponibile su richiesta.",
"savedCardBankTransferLink": "Ci contatti per organizzarlo.",
"savedCardAutoPayRequiredHeading": "Il pagamento automatico è obbligatorio",
"savedCardAutoPayRequiredBody": "PieCed IT opera con pagamento automatico tramite carta. Ci riserviamo il diritto di sospendere i tenant fino al saldo delle fatture pendenti in caso di fallimento della fatturazione automatica.",
"savedCardAutoPayDisabledNote": "Il pagamento automatico è attualmente disattivato. Le fatture future dovranno essere saldate manualmente — in caso di mancato pagamento ci riserviamo il diritto di sospendere i tenant associati a questo account."
}, },
"support": { "support": {
"title": "Supporto", "title": "Supporto",
@@ -764,7 +809,21 @@
"editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.", "editorIssueConfirm": "Emettere questa fattura ora? Verrà assegnato un numero di fattura, il PDF sarà inviato al cliente e questa bozza verrà rimossa.",
"editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.", "editorDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
"previewing": "Apertura…", "previewing": "Apertura…",
"issuing": "Emissione…" "issuing": "Emissione…",
"orgsTitle": "Fatturazione cliente",
"orgsDesc": "Modalità di pagamento + pagamento auto. per cliente",
"orgsPageTitle": "Modalità di fatturazione clienti",
"orgsPageSubtitle": "Override della modalità di pagamento per singoli clienti. Il pagamento tramite bonifico sostituisce l'addebito automatico su carta; mettere in pausa il pagamento automatico mantiene la carta salvata ma interrompe i tentativi di addebito (utile in caso di contestazioni).",
"orgsEmpty": "Ancora nessun cliente.",
"orgsColCustomer": "Cliente",
"orgsColCard": "Carta salvata",
"orgsColPayByInvoice": "Pagamento tramite bonifico",
"orgsColAutoCharge": "Pagamento automatico",
"orgsNoSavedCard": "nessuna",
"orgsPayByInvoiceOn": "attivo",
"orgsPayByInvoiceOff": "disattivo",
"orgsAutoChargeOn": "attivo",
"orgsAutoChargeOff": "disattivo"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Conferma costi di attivazione", "title": "Conferma costi di attivazione",

View File

@@ -253,6 +253,13 @@ export interface OrgBilling {
export type TenantRequestStatus = export type TenantRequestStatus =
| "pending" // Submitted, awaiting admin approval | "pending" // Submitted, awaiting admin approval
// Phase 9b: setup-fee Checkout pending. The row exists, has no
// tenant_name yet (set when payment succeeds), and is invisible
// to admin (the queue filters to status='pending'). On webhook
// success the row flips to 'pending'. On abandonment the row
// stays here harmlessly — each retry creates a fresh row with
// a different derived tenant_name.
| "pending_payment"
| "approved" // Admin approved, provisioning will start | "approved" // Admin approved, provisioning will start
| "provisioning" // PiecedTenant CR created, operator reconciling | "provisioning" // PiecedTenant CR created, operator reconciling
| "active" // Tenant running | "active" // Tenant running
@@ -283,6 +290,24 @@ export interface TenantRequest {
status: TenantRequestStatus; status: TenantRequestStatus;
adminNotes?: string; adminNotes?: string;
tenantName?: string; tenantName?: string;
/**
* Phase 9b: the paid setup-fee invoice linked to this request.
* Set by the Stripe webhook when the order-time Checkout
* completes successfully. Null on requests that pre-date Phase 9b
* and on resume requests (which don't have a setup fee). Admin
* rejection refunds this invoice via the existing refund flow.
*/
setupInvoiceId?: string | null;
/**
* Phase 9b: optional initial channel-user ids the customer entered
* during onboarding for each enabled channel package (e.g.
* { telegram: ["1234567"], threema: ["ABCD1234"] }). Empty/absent
* on requests that pre-date the field. Applied on admin approval:
* the values get seeded into PiecedTenantSpec.channelUsers, and
* for Threema specifically, the relay's route table is updated so
* inbound messages from those ids reach the newly-created tenant.
*/
channelUsers?: Record<string, string[]>;
encryptedSecrets?: Buffer | null; encryptedSecrets?: Buffer | null;
/** /**
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}` * Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
@@ -346,6 +371,14 @@ export interface OnboardingInput {
*/ */
billingAddress?: BillingAddress; billingAddress?: BillingAddress;
billingNotes?: string; billingNotes?: string;
/**
* Phase 9b: initial channel-user ids the customer entered during
* onboarding, keyed by channel package id (e.g. { telegram:
* ["1234567"], threema: ["ABCD1234"] }). Optional — customers
* can also leave channels blank and add ids later from the
* tenant's channel-users page.
*/
channelUsers?: Record<string, string[]>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -530,6 +563,29 @@ export interface OrgBillingConfig {
stripeCustomerId: string | null; stripeCustomerId: string | null;
autoInvoiceEnabled: boolean; autoInvoiceEnabled: boolean;
autoRemindersEnabled: boolean; autoRemindersEnabled: boolean;
/**
* Phase 9: saved-card info for off-session auto-charge.
* Populated by the SetupIntent webhook when a customer completes
* the "Set up auto-pay" flow. Only display fields are stored
* locally — never the PAN. The Stripe PaymentMethod id
* (`pm_xxx`) is the handle the platform uses to charge against
* the card; the brand/last4/exp_month/exp_year fields are for
* showing "Visa •••• 4242, expires 05/27" without an API call.
*/
stripeDefaultPaymentMethodId: string | null;
stripePmBrand: string | null;
stripePmLast4: string | null;
stripePmExpMonth: number | null;
stripePmExpYear: number | null;
/**
* Phase 9: off-session auto-charge gate. Default TRUE for new
* customers (card is the default payment method). Admin can
* flip this off to pause auto-charging for a specific customer
* (e.g. during a dispute) without removing the saved card. With
* no saved PaymentMethod set, the flag is irrelevant — there's
* nothing to charge against.
*/
autoChargeEnabled: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }