Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m44s

This commit is contained in:
2026-05-28 21:49:59 +02:00
parent 3fe3597553
commit d78f9f2696
6 changed files with 33 additions and 93 deletions

View File

@@ -4,7 +4,6 @@ import {
createTenantRequest,
createTenantRequestPendingPayment,
deletePendingPaymentRequest,
getOrgBillingConfig,
getTenantRequestById,
listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
@@ -417,29 +416,6 @@ export async function POST(request: Request) {
);
}
// Phase 9b (revised): a saved card on file IS the consent to
// auto-bill. There is no customer-facing "disable auto-pay"
// switch — ordering requires a card, full stop. The
// auto_charge_enabled flag is now an admin-only pause (used
// during disputes) and does NOT block a customer from ordering:
// if admin has paused recurring charges, that's a separate
// concern handled on the invoice side, not here. So the gate is
// simply: do they have a card on file?
const cfg = await getOrgBillingConfig(user.orgId);
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
if (!hasSavedCard) {
return NextResponse.json(
{
error:
"A payment card is required before ordering a new instance. " +
"Please save a card on /settings/billing, then submit again.",
code: "card_required",
redirectTo: "/settings/billing",
},
{ status: 402 }
);
}
// 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).
@@ -524,35 +500,33 @@ export async function POST(request: Request) {
tenantRequest.id
);
// Build the billing snapshot from the org's address (already
// fetched above for the wizard's billing-address resolution).
// The snapshot is what the invoice + Stripe customer use.
//
// orgBilling MUST exist here: the auto-pay pre-check above
// requires a saved Stripe PaymentMethod, which can only be
// created via ensureStripeCustomerForOrg, which requires
// org_billing. If it's missing the system is in an inconsistent
// state we shouldn't paper over.
if (!orgBilling) {
// 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 reached without org_billing for org ${user.orgId} — auto-pay pre-check should have prevented this.`
`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 on /settings/billing." },
{ error: "Billing record missing. Please re-save your billing details." },
{ status: 500 }
);
}
const billingSnapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
vatNumber: orgBilling.vatNumber ?? null,
billingEmail: orgBilling.billingEmail,
notes: orgBilling.notes ?? null,
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

View File

@@ -192,11 +192,6 @@ export function OnboardingWizard({
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// Phase 9b: 402 from the onboarding endpoint indicates the org
// needs to set up auto-pay before ordering. We render a tailored
// error block with a clickable link to /settings/billing rather
// than the generic red message.
const [autoPayRequired, setAutoPayRequired] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
@@ -444,7 +439,6 @@ export function OnboardingWizard({
setSubmitting(true);
setError("");
setAutoPayRequired(false);
try {
// Build secrets payload — only for packages that require them
@@ -491,19 +485,6 @@ export function OnboardingWizard({
}),
});
// Phase 9b (revised): 402 means the org needs a saved card
// before ordering. There's no "enable auto-pay" step anymore
// — a card on file is all that's required.
if (res.status === 402) {
const data = await res.json().catch(() => ({}));
if (data?.code === "card_required" || data?.code === "auto_pay_required") {
setAutoPayRequired(true);
setError(t("cardRequiredError"));
return;
}
throw new Error(data.error || "Submission failed");
}
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Submission failed");
@@ -811,8 +792,16 @@ export function OnboardingWizard({
</div>
</button>
{/* Inline credential inputs — expand when selected + requires secrets */}
{isSelected && pkg.requiresSecrets && (
{/* Inline expansion when selected — shows
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">
{pkg.instructionsKey && (
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
@@ -1275,17 +1264,6 @@ export function OnboardingWizard({
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
{error}
{autoPayRequired && (
<>
{" "}
<a
href="/settings/billing"
className="underline font-medium text-red-300 hover:text-red-200"
>
{t("autoPaySetupLink")}
</a>
</>
)}
</div>
)}

View File

@@ -123,11 +123,8 @@
"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.",
"reviewContactPersonPrefix": "z.Hd.",
"autoPayRequiredError": "Auto-Zahlung muss vor der Bestellung einer neuen Instanz eingerichtet sein. Richten Sie zuerst die Auto-Zahlung ein und senden Sie das Formular erneut.",
"autoPaySetupLink": "Karte hinzufügen →",
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um die einmalige Einrichtungsgebühr für diese Instanz zu bezahlen. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
"cardRequiredError": "Vor der Bestellung ist eine Zahlungskarte erforderlich. Fügen Sie eine Karte hinzu und senden Sie erneut.",
"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."
},

View File

@@ -123,11 +123,8 @@
"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.",
"reviewContactPersonPrefix": "Attn:",
"autoPayRequiredError": "Auto-pay is required before ordering a new instance. Set up auto-pay first, then submit again.",
"autoPaySetupLink": "Add a card →",
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to pay the one-time setup fee for this instance. 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.",
"cardRequiredError": "A payment card is required before ordering. Add a card, then submit again.",
"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"
},

View File

@@ -123,11 +123,8 @@
"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.",
"reviewContactPersonPrefix": "À l'attention de",
"autoPayRequiredError": "Le paiement automatique est requis avant de commander une nouvelle instance. Configurez d'abord le paiement automatique, puis soumettez à nouveau.",
"autoPaySetupLink": "Ajouter une carte →",
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour régler les frais d'activation uniques de cette instance. 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.",
"cardRequiredError": "Une carte de paiement est requise avant de commander. Ajoutez une carte, puis soumettez à nouveau.",
"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"
},

View File

@@ -123,11 +123,8 @@
"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.",
"reviewContactPersonPrefix": "c.a.",
"autoPayRequiredError": "Il pagamento automatico è obbligatorio prima di ordinare una nuova istanza. Configuri prima il pagamento automatico, poi invii nuovamente.",
"autoPaySetupLink": "Aggiungi una carta →",
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per pagare le spese di attivazione una tantum per questa istanza. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
"cardRequiredError": "Prima di ordinare è necessaria una carta di pagamento. Aggiunga una carta e invii nuovamente.",
"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"
},