This commit is contained in:
@@ -18,6 +18,32 @@ import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*/
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||
|
||||
/**
|
||||
* /settings/billing — customer-side billing details management.
|
||||
*
|
||||
* Owner-only by visibility: non-owner members get a 404 (same
|
||||
* response as if the page didn't exist). The link to this page
|
||||
* is also hidden from non-owners on /billing and elsewhere, but
|
||||
* the page itself enforces too — a non-owner who learns the URL
|
||||
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||
*
|
||||
* First-time visitors see an empty form. Subsequent visits see
|
||||
* the current values, editable. Save creates or updates via the
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*
|
||||
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||
* Visa •••• 4242, expires 05/27 / 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() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -25,7 +51,10 @@ export default async function BillingSettingsPage() {
|
||||
if (!user.roles.includes("owner")) notFound();
|
||||
|
||||
const t = await getTranslations("settingsBilling");
|
||||
const existing = await getOrgBilling(user.orgId);
|
||||
const [existing, config] = await Promise.all([
|
||||
getOrgBilling(user.orgId),
|
||||
getOrgBillingConfig(user.orgId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -43,6 +72,19 @@ export default async function BillingSettingsPage() {
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/app/api/billing/auto-charge/route.ts
Normal file
51
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { setAutoChargeEnabled } from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/billing/auto-charge
|
||||
*
|
||||
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
|
||||
* org. The body is `{ enabled: boolean }`.
|
||||
*
|
||||
* When OFF: invoices issued for this org won't trigger an
|
||||
* auto-charge against the saved card. The customer pays
|
||||
* manually (or admin marks paid) — same flow as a bank-transfer
|
||||
* customer.
|
||||
*
|
||||
* When ON: future invoice issuance attempts the auto-charge.
|
||||
* No effect if there's no saved card on file.
|
||||
*
|
||||
* Idempotent: setting OFF on an already-OFF flag is a no-op
|
||||
* (same outcome).
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
|
||||
return NextResponse.json({ enabled: parsed.data.enabled });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to update auto-charge setting") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/billing/saved-card/route.ts
Normal file
46
src/app/api/billing/saved-card/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/billing/setup-card/route.ts
Normal file
71
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
// Pick the base URL from the request's origin so redirects
|
||||
// work in dev (localhost), staging, and prod without env vars.
|
||||
const origin = new URL(request.url).origin;
|
||||
const session = await createSetupCheckoutSession({
|
||||
customerId,
|
||||
baseUrl: origin,
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Failed to start card setup") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
getPaymentMethodDisplay,
|
||||
getStripeClient,
|
||||
getWebhookSecret,
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getOrgIdByStripeCustomerId,
|
||||
isStripeRefundRecorded,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
@@ -161,6 +167,14 @@ export async function POST(request: Request) {
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): 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
|
||||
// complete in "unpaid" state (rare for mode=payment, more common
|
||||
// for async/delayed methods like SEPA). Only flip the invoice
|
||||
@@ -211,6 +225,97 @@ async function handleCheckoutCompleted(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||
|
||||
260
src/components/settings/saved-card-section.tsx
Normal file
260
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
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 }: Props) {
|
||||
const t = useTranslations("settingsBilling");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(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);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoCharge = async () => {
|
||||
setError("");
|
||||
setBusy("toggle");
|
||||
try {
|
||||
const res = await fetch("/api/billing/auto-charge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !autoChargeOn }),
|
||||
});
|
||||
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>
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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={toggleAutoCharge}
|
||||
disabled={busy !== null}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||
>
|
||||
{busy === "toggle"
|
||||
? t("saving")
|
||||
: autoChargeOn
|
||||
? t("savedCardDisableAutoChargeBtn")
|
||||
: t("savedCardEnableAutoChargeBtn")}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
146
src/lib/db.ts
146
src/lib/db.ts
@@ -421,6 +421,28 @@ const MIGRATION_SQL = `
|
||||
created_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.
|
||||
-- Created in Phase 1 so all billing schema is together; rows are
|
||||
@@ -2250,6 +2272,15 @@ function rowToOrgBillingConfig(row: any): OrgBillingConfig {
|
||||
stripeCustomerId: row.stripe_customer_id ?? null,
|
||||
autoInvoiceEnabled: row.auto_invoice_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,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
@@ -3985,3 +4016,118 @@ export async function deleteInvoiceDraft(id: string): Promise<boolean> {
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -318,3 +318,128 @@ export async function createInvoiceRefund(params: {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@
|
||||
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"createBilling": "Rechnungsdaten speichern",
|
||||
"saving": "Speichern…",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
|
||||
@@ -509,7 +509,24 @@
|
||||
"fullNameLabel": "Vor- und Nachname",
|
||||
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
|
||||
"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."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -509,7 +509,24 @@
|
||||
"fullNameLabel": "Full name",
|
||||
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
|
||||
"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."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -509,7 +509,24 @@
|
||||
"fullNameLabel": "Nom et prénom",
|
||||
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
|
||||
"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."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -509,7 +509,24 @@
|
||||
"fullNameLabel": "Nome e cognome",
|
||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
|
||||
"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."
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
|
||||
@@ -530,6 +530,29 @@ export interface OrgBillingConfig {
|
||||
stripeCustomerId: string | null;
|
||||
autoInvoiceEnabled: 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;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user