From 0ba3c9d10ca725ca024e8939ab253a4fadd57b25 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:58:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(catalog):=20sell=20online-payments=20?= =?UTF-8?q?=E2=80=94=20Connect=20is=20live=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sellable: false` kept online-payments out of the public SignupConfigurator and the founder's ProvisionPicker. Its original reason (an unbuilt surface) expired when S11 merged; what held it to the end was SOFRA-PAYMENTS-PLAN §7.6 — Connect unconfirmed on the LIVE Stripe platform, where `POST /v1/accounts` is provisioning's first call and refuses outright without it. Confirmed enabled on acct_1TpwTDCHzplJfkIy, 2026-08-10. Removing the flag broke two things outside the one-line diff: - The strings did not exist. The module had never rendered, so `signup.configurator.module.online-payments` and its hint were missing from all six locales and /signup showed the raw keys. The parity check diffs the other five AGAINST en.json, so a key absent from all six reads as "in parity"; SignupConfigurator builds both keys by template literal, so TypeScript cannot see them either. A unit test now asserts every sellable module has a label and a hint in en.json — parity carries the other five. Verified it fails when the hint is deleted. - The marketing site contradicted the price list. faq.items.payments and compare.table.rows.payments.sofra both said online payments were "on the roadmap — today you charge at the counter", in six locales, while the configurator offered the feature at EUR 19. The comparison page ran that against GloriaFood's "US$29/month" — a competitive claim wrong in our own disfavour. Rewritten; the FAQ now also states the Connect KYC precondition, which nothing had told a customer. The hint says "local methods like TWINT or iDEAL" rather than TWINT alone: methods are chosen dynamically per connected account, and TWINT does not exist for a Dutch tenant. The bundle-exclusion test above is now vacuous (no module carries the flag) and says so — it is kept for the next module that arrives id-first. Verified on a dev server: /signup lists the module at EUR 19 and prices a selection at EUR 38, /en#faq and /en/compare/gloriafood render the new copy, and /ar/signup renders it RTL with no raw keys. Refs SOFRA-PAYMENTS-PLAN §7.6 --- lib/module-catalog.ts | 5 ---- messages/ar.json | 12 +++++---- messages/de.json | 10 ++++--- messages/en.json | 10 ++++--- messages/fr.json | 10 ++++--- messages/nl.json | 10 ++++--- messages/tr.json | 12 +++++---- tests/unit/module-catalog.test.ts | 45 ++++++++++++++++++++++++++----- 8 files changed, 76 insertions(+), 38 deletions(-) diff --git a/lib/module-catalog.ts b/lib/module-catalog.ts index 1780e3f..b852896 100644 --- a/lib/module-catalog.ts +++ b/lib/module-catalog.ts @@ -61,11 +61,6 @@ export const MODULES: readonly CatalogModule[] = [ id: "online-payments", priceCents: 1900, surface: "card/TWINT at checkout, paid to the restaurant's own Stripe account", - // NOT YET SELLABLE. The vocabulary lands first (S10) so provisioning accepts the id and the - // registry can record a stripe_account; the endpoint that would honour it arrives in S4 and - // the customer-facing choice in S8. Until then this must not appear on the signup page or in - // the founder's provision picker. Remove this line in S9, when the flow works end to end. - sellable: false, }, { id: "extra-languages", priceCents: 500, surface: "beyond Core's en + 1, up to 10 locales" }, ] as const; diff --git a/messages/ar.json b/messages/ar.json index 3985f40..412851b 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -220,8 +220,8 @@ "a": "خلال الوصول المبكر لا تدفع المطاعم المؤسِّسة شيئًا — الإعداد والدعم على حسابنا، وتحتفظ المطاعم المؤسِّسة بخصم مدى الحياة عند صدور الأسعار العادية. ستكون الخطط بسيطة: سعر شهري ثابت مع وحدات إضافية اختيارية." }, "payments": { - "q": "هل يستطيع ضيوفي الدفع عبر الإنترنت — بما في ذلك TWINT؟", - "a": "المدفوعات عبر الإنترنت على خارطة الطريق كوحدة إضافية، مع وسائل دفع محلية مثل TWINT في سويسرا وiDEAL في هولندا إلى جانب البطاقات. أما اليوم فتصل الطلبات إلى الكاونتر وتحصِّل كما اعتدت دائمًا." + "q": "هل يمكن لضيوفي الدفع عبر الإنترنت — بما في ذلك TWINT؟", + "a": "نعم — مع وحدة الدفع عبر الإنترنت يدفع ضيوفك بالبطاقة، إضافةً إلى الطرق المحلية مثل TWINT في سويسرا وiDEAL في هولندا. تصل الأموال مباشرةً إلى حساب Stripe الخاص بك ولا نأخذ أي عمولة. قبل أول عملية دفع تُكمل تحقّق Stripe لمرة واحدة. ويمكنك دائمًا الاستمرار في التحصيل عند الكاونتر." }, "languages": { "q": "ما اللغات التي تدعمها القائمة؟", @@ -368,7 +368,7 @@ }, "payments": { "label": "الدفع عبر الإنترنت", - "sofra": "على خارطة الطريق كوحدة اختيارية — وسائل محلية مثل TWINT وiDEAL مخطط لها إلى جانب البطاقات. واليوم تُحصّل عند الكاشير كالمعتاد.", + "sofra": "وحدة إضافية بـ 19 يورو شهريًا — بطاقة وطرق محلية مثل TWINT وiDEAL، مباشرةً إلى حساب Stripe الخاص بك. بدون عمولة.", "gf": "متاح كوحدة مدفوعة — الدفع عبر الإنترنت/بالبطاقة بـ29 دولارًا أمريكيًا شهريًا." } } @@ -927,7 +927,8 @@ "server": "خدمة الطاولات", "reservations": "الحجوزات", "loyalty": "نقاط الولاء", - "printing": "طباعة الإيصالات" + "printing": "طباعة الإيصالات", + "online-payments": "الدفع عبر الإنترنت" }, "moduleHint": { "kitchen-board": "لوحة طلبات مباشرة للمطبخ", @@ -935,7 +936,8 @@ "server": "مخطط الصالة والطلب على الطاولة", "reservations": "الحجز عبر الإنترنت وإدارة الطاولات", "loyalty": "النقاط ومجموعات العملاء والخصومات", - "printing": "تطبيق مرافق للطابعات الحرارية" + "printing": "تطبيق مرافق للطابعات الحرارية", + "online-payments": "بطاقات وطرق محلية مثل TWINT أو iDEAL، مباشرةً إلى حساب Stripe الخاص بك" }, "template": { "classic": "كلاسيكي", diff --git a/messages/de.json b/messages/de.json index cddd740..7af3cf2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -221,7 +221,7 @@ }, "payments": { "q": "Können meine Gäste online bezahlen — auch mit TWINT?", - "a": "Online-Zahlungen stehen als Zusatzmodul auf der Roadmap, mit lokalen Zahlungsmitteln wie TWINT in der Schweiz und iDEAL in den Niederlanden neben Karten. Heute laufen Bestellungen an Ihre Theke und Sie kassieren wie gewohnt." + "a": "Ja — mit dem Modul Online-Zahlungen zahlen Ihre Gäste per Karte sowie mit lokalen Methoden wie TWINT in der Schweiz und iDEAL in den Niederlanden. Das Geld geht direkt auf Ihr eigenes Stripe-Konto, wir nehmen keine Provision. Vor der ersten Zahlung durchlaufen Sie einmalig die Verifizierung von Stripe. Am Tresen kassieren können Sie weiterhin." }, "languages": { "q": "Welche Sprachen unterstützt die Karte?", @@ -368,7 +368,7 @@ }, "payments": { "label": "Online-Zahlungen", - "sofra": "Auf der Roadmap als Zusatzmodul — lokale Methoden wie TWINT und iDEAL neben Karten geplant. Heute kassieren Sie am Tresen wie gewohnt.", + "sofra": "Zusatzmodul für 19 €/Monat — Karte plus lokale Methoden wie TWINT und iDEAL, direkt auf Ihr eigenes Stripe-Konto. Ohne Provision.", "gf": "Als bezahltes Modul verfügbar — Online-/Kartenzahlungen für 29 US$/Monat." } } @@ -927,7 +927,8 @@ "server": "Tischservice", "reservations": "Reservierungen", "loyalty": "Treuepunkte", - "printing": "Bondruck" + "printing": "Bondruck", + "online-payments": "Online-Zahlungen" }, "moduleHint": { "kitchen-board": "Live-Bestelltafel für die Küche", @@ -935,7 +936,8 @@ "server": "Tischplan und Bestellung am Tisch", "reservations": "Online-Buchung und Tischverwaltung", "loyalty": "Punkte, Kundengruppen und Rabatte", - "printing": "Begleit-App für Thermodrucker" + "printing": "Begleit-App für Thermodrucker", + "online-payments": "Karte und lokale Methoden wie TWINT oder iDEAL, direkt auf Ihr eigenes Stripe-Konto" }, "template": { "classic": "Klassisch", diff --git a/messages/en.json b/messages/en.json index fb07a9a..471b843 100644 --- a/messages/en.json +++ b/messages/en.json @@ -221,7 +221,7 @@ }, "payments": { "q": "Can my guests pay online — including TWINT?", - "a": "Online payments are on the roadmap as an add-on module, with local payment methods like TWINT in Switzerland and iDEAL in the Netherlands planned alongside cards. Today, orders flow to your counter and you charge as you always have." + "a": "Yes — add the Online payments module and your guests pay by card, with local methods like TWINT in Switzerland and iDEAL in the Netherlands. The money goes straight into your own Stripe account and we take no commission. You complete Stripe's one-time verification before your first payment. You can always keep charging at the counter as well." }, "languages": { "q": "Which languages does the menu support?", @@ -368,7 +368,7 @@ }, "payments": { "label": "Online payments", - "sofra": "On the roadmap as an add-on module — local methods like TWINT and iDEAL planned alongside cards. Today you charge at the counter as usual.", + "sofra": "Add-on module at €19/month — card plus local methods like TWINT and iDEAL, paid straight into your own Stripe account. No commission.", "gf": "Available as a paid module — online / card payments at US$29/month." } } @@ -927,7 +927,8 @@ "server": "Table service", "reservations": "Reservations", "loyalty": "Loyalty points", - "printing": "Receipt printing" + "printing": "Receipt printing", + "online-payments": "Online payments" }, "moduleHint": { "kitchen-board": "Live order board for the kitchen", @@ -935,7 +936,8 @@ "server": "Floor plan and waiter ordering", "reservations": "Online booking and table management", "loyalty": "Points, customer groups and discounts", - "printing": "Companion app for thermal printers" + "printing": "Companion app for thermal printers", + "online-payments": "Cards and local methods like TWINT or iDEAL, paid into your own Stripe account" }, "template": { "classic": "Classic", diff --git a/messages/fr.json b/messages/fr.json index 156c68a..75a6f71 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -221,7 +221,7 @@ }, "payments": { "q": "Mes clients peuvent-ils payer en ligne — y compris avec TWINT ?", - "a": "Le paiement en ligne arrive comme module optionnel, avec les moyens de paiement locaux comme TWINT en Suisse et iDEAL aux Pays-Bas prévus aux côtés des cartes. Aujourd'hui, les commandes arrivent à votre comptoir et vous encaissez comme d'habitude." + "a": "Oui — avec le module Paiement en ligne, vos clients paient par carte, ainsi qu'avec les méthodes locales comme TWINT en Suisse et iDEAL aux Pays-Bas. L'argent arrive directement sur votre propre compte Stripe et nous ne prenons aucune commission. Avant le premier paiement, vous effectuez une vérification unique auprès de Stripe. Vous pouvez toujours encaisser au comptoir." }, "languages": { "q": "Quelles langues le menu prend-il en charge ?", @@ -368,7 +368,7 @@ }, "payments": { "label": "Paiement en ligne", - "sofra": "Sur la feuille de route comme module optionnel — TWINT et iDEAL prévus aux côtés des cartes. Aujourd'hui, vous encaissez au comptoir comme d'habitude.", + "sofra": "Module en option à 19 €/mois — carte et méthodes locales comme TWINT et iDEAL, versées directement sur votre propre compte Stripe. Sans commission.", "gf": "Disponible en module payant — paiements en ligne / par carte à 29 $US/mois." } } @@ -927,7 +927,8 @@ "server": "Service à table", "reservations": "Réservations", "loyalty": "Points de fidélité", - "printing": "Impression des tickets" + "printing": "Impression des tickets", + "online-payments": "Paiement en ligne" }, "moduleHint": { "kitchen-board": "Tableau des commandes en direct pour la cuisine", @@ -935,7 +936,8 @@ "server": "Plan de salle et prise de commande", "reservations": "Réservation en ligne et gestion des tables", "loyalty": "Points, groupes de clients et remises", - "printing": "Application compagnon pour imprimantes thermiques" + "printing": "Application compagnon pour imprimantes thermiques", + "online-payments": "Carte et méthodes locales comme TWINT ou iDEAL, versées sur votre propre compte Stripe" }, "template": { "classic": "Classique", diff --git a/messages/nl.json b/messages/nl.json index 952db24..7e52729 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -221,7 +221,7 @@ }, "payments": { "q": "Kunnen mijn gasten online betalen — ook met iDEAL of TWINT?", - "a": "Online betalen staat op de roadmap als extra module, met lokale betaalmethoden zoals iDEAL in Nederland en TWINT in Zwitserland naast kaarten. Vandaag komen bestellingen binnen aan je balie en reken je af zoals je gewend bent." + "a": "Ja — met de module Online betalen betalen je gasten met kaart, plus lokale methoden zoals iDEAL in Nederland en TWINT in Zwitserland. Het geld komt direct op je eigen Stripe-account en wij nemen geen commissie. Vóór je eerste betaling doorloop je eenmalig de verificatie van Stripe. Afrekenen aan de balie blijft gewoon mogelijk." }, "languages": { "q": "Welke talen ondersteunt de menukaart?", @@ -368,7 +368,7 @@ }, "payments": { "label": "Online betalen", - "sofra": "Op de planning als optionele module — lokale methoden zoals iDEAL en TWINT gepland naast kaarten. Vandaag reken je af aan de balie zoals altijd.", + "sofra": "Extra module voor € 19/maand — kaart plus lokale methoden zoals iDEAL en TWINT, direct op je eigen Stripe-account. Geen commissie.", "gf": "Beschikbaar als betaalde module — online-/kaartbetalingen voor US$ 29/maand." } } @@ -927,7 +927,8 @@ "server": "Tafelbediening", "reservations": "Reserveringen", "loyalty": "Loyaliteitspunten", - "printing": "Bonnen printen" + "printing": "Bonnen printen", + "online-payments": "Online betalen" }, "moduleHint": { "kitchen-board": "Live besteloverzicht voor de keuken", @@ -935,7 +936,8 @@ "server": "Plattegrond en bestellen aan tafel", "reservations": "Online reserveren en tafelbeheer", "loyalty": "Punten, klantgroepen en kortingen", - "printing": "Companion-app voor thermische printers" + "printing": "Companion-app voor thermische printers", + "online-payments": "Kaart en lokale methoden zoals iDEAL of TWINT, direct op je eigen Stripe-account" }, "template": { "classic": "Klassiek", diff --git a/messages/tr.json b/messages/tr.json index e2999bb..a099fbf 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -220,8 +220,8 @@ "a": "Erken erişim boyunca kurucu restoranlar hiçbir şey ödemez — kurulum ve destek ikramımızdır; normal fiyatlar geldiğinde kurucuların indirimi ömür boyu sürer. Planlar sade olacak: sabit aylık fiyat, isteğe bağlı ek modüller." }, "payments": { - "q": "Misafirlerim online ödeyebilir mi — TWINT dahil?", - "a": "Online ödeme, ek modül olarak yol haritamızda; kartların yanında İsviçre'de TWINT, Hollanda'da iDEAL gibi yerel ödeme yöntemleri de planlandı. Bugün siparişler kasanıza düşer, her zamanki gibi tahsil edersiniz." + "q": "Misafirlerim online ödeme yapabilir mi — TWINT dahil?", + "a": "Evet — Online ödeme modülüyle misafirleriniz kartla, ayrıca İsviçre'de TWINT ve Hollanda'da iDEAL gibi yerel yöntemlerle ödeyebilir. Para doğrudan kendi Stripe hesabınıza geçer, biz komisyon almayız. İlk ödemeden önce Stripe'ın tek seferlik doğrulamasını tamamlarsınız. Kasada tahsil etmeye de devam edebilirsiniz." }, "languages": { "q": "Menü hangi dilleri destekliyor?", @@ -368,7 +368,7 @@ }, "payments": { "label": "Çevrim içi ödeme", - "sofra": "Yol haritasında isteğe bağlı modül olarak — kartların yanında TWINT ve iDEAL gibi yerel yöntemler planlı. Bugün her zamanki gibi kasada tahsil edersiniz.", + "sofra": "Ayda 19 € ek modül — kart ve TWINT, iDEAL gibi yerel yöntemler, doğrudan kendi Stripe hesabınıza. Komisyon yok.", "gf": "Ücretli modül olarak mevcut — çevrim içi/kartla ödeme 29 ABD$/ay." } } @@ -927,7 +927,8 @@ "server": "Masa servisi", "reservations": "Rezervasyonlar", "loyalty": "Sadakat puanları", - "printing": "Fiş yazdırma" + "printing": "Fiş yazdırma", + "online-payments": "Online ödeme" }, "moduleHint": { "kitchen-board": "Mutfak için canlı sipariş panosu", @@ -935,7 +936,8 @@ "server": "Salon planı ve masada sipariş", "reservations": "Çevrimiçi rezervasyon ve masa yönetimi", "loyalty": "Puanlar, müşteri grupları ve indirimler", - "printing": "Termal yazıcılar için yardımcı uygulama" + "printing": "Termal yazıcılar için yardımcı uygulama", + "online-payments": "Kart ve TWINT ya da iDEAL gibi yerel yöntemler, doğrudan kendi Stripe hesabınıza" }, "template": { "classic": "Klasik", diff --git a/tests/unit/module-catalog.test.ts b/tests/unit/module-catalog.test.ts index b670ac8..2be62c0 100644 --- a/tests/unit/module-catalog.test.ts +++ b/tests/unit/module-catalog.test.ts @@ -9,6 +9,7 @@ import { unknownModuleIds, } from "@/lib/module-catalog"; import { TENANT_LANGUAGES, unknownLanguages } from "@/lib/tenant-options"; +import en from "@/messages/en.json"; describe("catalog shape", () => { it("prices every module id exactly once, in whole cents", () => { @@ -21,20 +22,50 @@ describe("catalog shape", () => { it("never bundles a module that is not sellable yet", () => { // A bundle is a purchase, so it must not smuggle in a module the à-la-carte surfaces - // deliberately hide. online-payments is in the vocabulary from S10 but has no working - // surface until S9 — putting it inside "full-service" would sell it anyway. + // deliberately hide. + // + // VACUOUS TODAY, ON PURPOSE: online-payments was the only flagged module and the flag is + // gone, so `notSellable` is empty and this passes having examined nothing. Kept because the + // next unfinished module arrives the same way — the id must exist for provisioning to accept + // it before its surface is built — and this is what stops it being sold inside a bundle. + // Do not read a green run here as evidence about online-payments; the case below covers that. const notSellable = new Set(MODULES.filter((m) => m.sellable === false).map((m) => m.id)); for (const b of BUNDLES) { expect(b.modules.filter((m) => notSellable.has(m))).toEqual([]); } }); - it("keeps an unfinished module out of the vocabulary's sellable set", () => { - // The vocabulary and the price list are the same array, so adding an id to make - // provisioning accept it also makes it purchasable unless it is flagged. This is the - // assertion that catches that: online-payments must be KNOWN and NOT sellable. + it("offers online-payments for purchase, now that a tenant can be given it", () => { + // The inverse of the assertion this replaces. The flag outlived its original reason (the + // surface being unbuilt): what kept it false to the end was Connect being unconfirmed on the + // LIVE Stripe platform — `POST /v1/accounts` is provisioning's first call and refuses + // outright without it, so listing the module would have sold something provisioning could + // not deliver. Connect confirmed enabled on acct_1TpwTDCHzplJfkIy, 2026-08-10. + // + // `not.toBe(false)` rather than `toBeUndefined()`: absent is what the type means by sellable, + // and pinning the exact absence would fail a future `sellable: true` saying the same thing. expect(isModuleId("online-payments")).toBe(true); - expect(MODULES.find((m) => m.id === "online-payments")?.sellable).toBe(false); + expect(MODULES.find((m) => m.id === "online-payments")?.sellable).not.toBe(false); + }); + + it("names every sellable module in the configurator's messages", () => { + // The gate the parity check cannot be. `check-message-parity.mjs` diffs the five other + // locales AGAINST en.json, so a key missing from all six is "in parity" — which is exactly + // how online-payments reached the live signup page rendering the raw key + // `signup.configurator.module.online-payments`. Nothing else could have caught it either: + // SignupConfigurator builds both keys by template literal, so TypeScript never sees them. + // + // Anchored on en because parity carries the other five: en having it and the rest missing it + // is the one shape the parity script does fail on. + // The two exclusions mirror SignupConfigurator's own filter: extra-languages never appears + // as a checkbox (the language picker prices it), and core is a fixed line with no hint. + const { module: labels, moduleHint: hints } = en.signup.configurator; + for (const m of MODULES) { + if (m.sellable === false || m.id === "extra-languages") continue; + expect(labels, `label for ${m.id}`).toHaveProperty(m.id); + if (m.id === "core") continue; + expect(hints, `hint for ${m.id}`).toHaveProperty(m.id); + } }); it("only bundles modules that exist, and always includes core", () => { From 372aaa9c82e7136b661f3255048d360f09e56595 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:30:52 +0200 Subject: [PATCH 2/2] fix(provisioning): never propose an entry provisioning refuses (P1) (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tenant who buys `online-payments` generated a registry entry that `provision-tenant.sh` refuses: the guard requires the module and a `stripe_account:` together, and it exits 1 BEFORE the database, the compose project and the image. So the customer did not get a restaurant without card payment — they got no restaurant at all, and the founder learned from the auto-opened deploy issue while a paid customer waited. The module and the account now ship as a pair or not at all: - Founder path — `/admin/provision` gains an optional Stripe account field. The runbook (§2b) already has them create the connected account BEFORE proposing, precisely because of this guard, so they arrive holding the `acct_` and the entry carries both halves in one shot. - Self-serve path — the buyer has no account and cannot be given one (only the restaurant can create it, through Stripe's hosted onboarding, which cannot be pre-filled). Their module is withheld, they go live on everything else, and the PR body names what was bought, why it is absent, and the exact two-field follow-up PR. `deferred` is returned from `openProvisioningPr` and recorded on both audit entries: a prose section in one PR is not a record anyone can query later, and a deferral means a customer is being billed for a module their tenant does not yet have. `buildProvisioningPrBody` moves to `lib/provisioning-pr-body.ts` — the pair outgrew the 200 LOC limit — and is added to the coverage `include` so the move does not drop already-covered code out of the floor's scope. The guard's own shell condition is evaluated by a real bash in the unit suite against both shapes, so the test pins the generator to the deploy repo's text rather than to a paraphrase of it. Co-authored-by: Claude Opus 5 --- components/control/ProvisionForm.tsx | 17 ++ docs/adr/ADR-012-auto-provisioning-trigger.md | 9 + lib/actions/provisioning-actions.ts | 10 +- lib/auto-provision-policy.ts | 8 +- lib/auto-provision.ts | 11 +- lib/provisioning-pr-body.ts | 172 ++++++++++++++ lib/provisioning-registry.ts | 186 ++++++--------- lib/provisioning.ts | 19 +- lib/validation.ts | 12 + messages/ar.json | 2 + messages/de.json | 2 + messages/en.json | 2 + messages/fr.json | 2 + messages/nl.json | 2 + messages/tr.json | 2 + tests/unit/provisioning-registry.test.ts | 215 +++++++++++++++++- tests/unit/validation.test.ts | 18 ++ vitest.config.ts | 5 + 18 files changed, 561 insertions(+), 133 deletions(-) create mode 100644 lib/provisioning-pr-body.ts diff --git a/components/control/ProvisionForm.tsx b/components/control/ProvisionForm.tsx index 0264f4a..05da786 100644 --- a/components/control/ProvisionForm.tsx +++ b/components/control/ProvisionForm.tsx @@ -91,6 +91,23 @@ export default function ProvisionForm({ aria-label={t("provision.currency")} className="input-primary" /> + {/* Optional, and deliberately NOT prefilled from a signup: a lead has no connected + account (only the restaurant can create one, via Stripe's hosted onboarding). + It is here for the founder path, where runbook §2b creates the account BEFORE + proposing — with it the entry carries `online-payments` in one shot, without it + the generator holds the module back rather than proposing an entry that + provision-tenant.sh refuses. */} + undefined); // no plan for this slug: founder-proposed, nothing to record - await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, { prUrl }); + // `deferred` only when non-empty: an always-present `[]` reads as a field nobody set + // rather than as the absence of a withheld module. + await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, { + prUrl, + ...(deferred.length ? { deferred } : {}), + }); return { ok: true, prUrl }; } catch (e) { if (e instanceof ProvisioningNotConfiguredError) return { error: "provisioningNotConfigured" }; diff --git a/lib/auto-provision-policy.ts b/lib/auto-provision-policy.ts index 0f1cf37..dd87423 100644 --- a/lib/auto-provision-policy.ts +++ b/lib/auto-provision-policy.ts @@ -23,7 +23,13 @@ export type AutoProposePlan = | { kind: "skipped"; reason: AutoProposeSkip } | { kind: "failed"; detail: string }; -export type AutoProposeOutcome = Exclude | { kind: "opened"; prUrl: string }; +export type AutoProposeOutcome = + | Exclude + // `deferred` = modules the buyer PAID for that the proposed entry withholds, because + // provisioning refuses them without a Stripe account the self-serve buyer cannot have + // yet. Carried on the outcome so it reaches the audit trail: this is the only durable + // record that someone is being billed for a module their tenant does not yet have. + | { kind: "opened"; prUrl: string; deferred?: string[] }; /** The already-validated configuration a lead recorded, plus the slug it must match. */ export type AutoProposeConfig = { diff --git a/lib/auto-provision.ts b/lib/auto-provision.ts index f9bbfc8..f860dcb 100644 --- a/lib/auto-provision.ts +++ b/lib/auto-provision.ts @@ -85,7 +85,7 @@ export async function autoProposeProvisioning(billingId: string): Promise + "'" + value.replaceAll("'", SHELL_QUOTED_APOSTROPHE) + "'"; + +/** Collapse anything that would break the markdown fence or the shell command this + * body embeds. `provisionSchema` already refuses control characters in `name`, so in + * practice this changes nothing — it is here so the function is safe on its own, + * because a body builder that depends on a caller's validation is one refactor away + * from emitting an unbalanced code fence built from public-form input. */ +const oneLine = (value: string): string => value.replace(/\s+/g, " ").trim(); + +/** + * The PR body for a provisioning proposal. + * + * **For a staging-box tenant, merging this PR provisions it** (SOFRA-ONBOARDING-PLAN §2 + * option B, ADR-012 amendment 2026-07-30): the deploy repo's + * `provision-on-registry-merge.yml` chains the image build and `provision-tenant.sh` off + * the registry sync. So the body leads with what to CHECK before merging — the merge is + * the last reversible moment. + * + * That chain is **staging-only** (it follows `sync-registry-to-staging.yml` and inherits + * its narrowness), so a `box: prod` entry gets the opposite header: merging does nothing + * and the commands are required, not a fallback. Telling a prod entry "merging provisions + * this" would leave the founder waiting on a chain that never runs. + * + * The image-build command stays in the body either way, because that step is the one that + * is easy to skip and fatal to skip: `NEXT_PUBLIC_*` are baked per domain, so provisioning + * without it dies at `docker compose pull` on an image that was never published. + */ +export function buildProvisioningPrBody(input: TenantProvisionInput): string { + const { slug } = input; + const domain = `${slug}.sofrapiwas.com`; + const box = input.box ?? "staging"; + const chained = box === "staging"; + // Same helper the entry generator uses, so the body cannot describe a split the diff + // does not have. + const { granted, deferred } = splitDeferredModules(input.modules, input.stripeAccount); + + // One line, naming the one field in the diff the founder may need to change. It used to + // branch on the box and warn that a staging-box tenant rides develop; the generator no + // longer produces that entry, so warning about it would be an unfalsifiable checkbox. + const tagCheck = + "- [ ] **`backend_tag: latest`** — released code, published only from `main`. If this is a develop-tracking **showcase** rather than a customer, change it to `staging` in Files changed before merging; a customer should stay on `latest`, so their database is never migrated by unreleased code"; + + const header = chained + ? [ + "### ⚠️ Merging this PR provisions the tenant", + "", + "`provision-on-registry-merge.yml` builds the per-tenant frontend image and then runs", + "`provision-tenant.sh` on the box — roughly 15 minutes, hands-off. **This is the human", + "checkpoint, and it is the last reversible moment.** Before you merge:", + ] + : [ + `### Merging this PR does **not** provision — \`box: ${box}\``, + "", + "The post-merge chain is staging-only. This entry will be reported and skipped, so the", + "two commands below are **required**, not a fallback. Still check the entry first:", + ]; + + // Named, not silent. The entry omits a module they PAID for, so the body has to say + // so where the founder is already reading — otherwise the checklist above quietly + // contradicts the receipt, and the gap is discovered by a customer asking why card + // payment does not work. Empty for every tenant that bought nothing deferred. + const deferredBlock = deferred.length + ? [ + "", + `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``, + "", + `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`, + "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`", + "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*", + "the database, the compose project or the image — so proposing both here would not give", + "them a restaurant lacking card payment, it would give them **no restaurant at all**.", + "", + "No account was supplied with this proposal. If you are the founder and you already", + "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", + "this does not happen — the fix is one shot, not two: add both fields in **Files", + "changed** before merging and delete this section's premise. Otherwise the account", + "genuinely cannot exist yet, because only the restaurant can create it, through", + "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", + "now on everything else, then, once they have finished Stripe and you have their id, open a", + "**second** registry PR that adds BOTH halves together — one without the other trips", + "the same guard. Only these two fields change; the rest of the entry stays as merged:", + "", + "```yaml", + ` ${slug}:`, + " stripe_account: acct_XXXXXXXXXXXX", + ` modules: [${[...granted, ...deferred].join(", ")}]`, + "```", + "", + `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, + "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", + "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", + "**§2b**, which is written to be followed BEFORE this second PR.", + ] + : []; + + const after = chained + ? [ + "The chain provisions **first-time only**, and reports back on this PR when it is done —", + "or opens an issue on the deploy repo if any stage fails, including the registry sync it", + "waits on. A tenant it has already finished is skipped, so re-merging or", + "reverting-and-remerging this PR will not provision twice. One it left part-way through is", + "*completed* rather than skipped, so a retry is always safe.", + ] + : [ + "Merging still fires `sync-registry-to-staging.yml`, which copies the registry to the", + "**staging** box only. A prod-box tenant needs the prod box's own access (ADR-012", + "per-box boundary), so run the commands from a machine that has it.", + ]; + + return [ + `Adds the \`${slug}\` tenant to \`tenants/registry.yml\`, proposed by the control plane (sofra ADR-012).`, + "", + `- **domain** \`${domain}\` · **template** \`${input.template}\` · **currency** \`${input.currency}\``, + `- **languages** \`${input.languages.join(", ")}\` · **modules** \`${granted.join(", ")}\`${ + deferred.length ? ` · **deferred** \`${deferred.join(", ")}\` (see below)` : "" + }`, + `- **box** \`${box}\` · status starts at \`provisioning\``, + "", + ...header, + "", + `- [ ] the **slug** \`${slug}\` is what the customer should live on forever — it is the subdomain, database, role and compose project, and changing it later is a full re-provision`, + // With a deferral the "must match what they paid for" wording would be false by + // construction, and a checkbox the founder must tick while knowing it is wrong is + // how the whole checklist stops being read. So the ask changes with the diff. + deferred.length + ? `- [ ] **modules** \`${granted.join(", ")}\` are everything they paid for EXCEPT \`${deferred.join(", ")}\`, which is held back on purpose — see the section below. They are enforced at runtime, so any *other* missing id is a feature they bought and will not get` + : `- [ ] **modules** \`${granted.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`, + tagCheck, + `- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`, + ...deferredBlock, + "", + ...after, + "", + `Afterwards: \`./verify-env.sh https://${domain}\`, hand over the generated admin password from the tenant \`.env\` (and have them change it), then flip this entry's \`status\` to \`active\` in a follow-up commit.`, + "", + chained ? "### If the chain fails" : "### Run these after merging", + "", + "Both are idempotent and safe to re-run:", + "", + "```bash", + "gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend \\", + ` -f tenant_domain=${domain} \\`, + ` -f image_tag=tenant-${slug} \\`, + ` -f restaurant_name=${shq(oneLine(input.name))} \\`, + ` -f template=${input.template} \\`, + ` -f currency=${input.currency}`, + "", + `gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`, + "```", + "", + "Full runbook: deploy repo `DEPLOYMENT.md` §Tenant provisioning.", + ].join("\n"); +} diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index 432561b..f72f73e 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -6,6 +6,54 @@ // so it stays unit-testable + free of the GitHub API / secrets. import { stringify } from "yaml"; +import type { ModuleId } from "./module-catalog"; + +/** + * Modules `provision-tenant.sh` refuses unless the SAME entry also records a + * `stripe_account:`. That guard `exit 1`s *before* the database, the compose project + * and the image, so proposing the module without the account does not yield a tenant + * lacking card payment — it yields no tenant at all. + * + * Hence the pairing rule below: the module ships only alongside an account, never on + * its own. Which half is missing depends on the path in, and BOTH paths reach this one + * generator: + * + * - **Self-serve.** The buyer has no `acct_` and cannot be given one — only the + * restaurant can create it, through Stripe's hosted onboarding, which cannot be + * pre-filled (`oauth_not_supported` on a Standard account, SOFRA-PAYMENTS-PLAN §3). + * So the module is deferred to a second registry PR and the PR body says so. + * - **Founder.** `docs/runbooks/signup-to-live-tenant.md` §2b has the founder create + * the account BEFORE proposing, precisely because of this guard — so they arrive + * holding the `acct_`, and the entry carries both halves in one shot. + * + * Deferring unconditionally would have been wrong for the second path: it would make + * the founder's documented order pointless and tell them, falsely, that no account can + * exist yet. + */ +const ACCOUNT_PAIRED_MODULE_IDS: readonly ModuleId[] = ["online-payments"]; + +/** + * Split a purchased module list into what this entry may carry now and what must wait + * for a second registry PR. Pure and shared, so the entry and the PR body describing it + * cannot disagree about which is which. + * + * `stripeAccount` is the whole hinge: with one, nothing is deferred; without one, the + * account-paired ids are held back. + */ +export function splitDeferredModules( + modules: string[], + stripeAccount?: string, +): { granted: string[]; deferred: string[] } { + // Whitespace-only is not an account: `provision-tenant.sh` tests `-z`, which a blank + // string passes and " " does not — so a stray space would sail past the guard here and + // then fail on the box, which is the one place this must never be discovered. + if (stripeAccount?.trim()) return { granted: modules, deferred: [] }; + const isPaired = (id: string) => (ACCOUNT_PAIRED_MODULE_IDS as readonly string[]).includes(id); + return { + granted: modules.filter((id) => !isPaired(id)), + deferred: modules.filter(isPaired), + }; +} export interface TenantProvisionInput { /** Registry key + derivation seed. Must already match the slug grammar. */ @@ -16,6 +64,9 @@ export interface TenantProvisionInput { currency: string; languages: string[]; modules: string[]; + /** The tenant's Stripe connected account (`acct_…`), when they already have one. + * Absent on the self-serve path; present when the founder followed runbook §2b. */ + stripeAccount?: string; city?: string; /** Which box the tenant belongs on; provision-tenant.sh refuses a mismatch. */ box?: string; @@ -29,10 +80,20 @@ export interface TenantProvisionInput { * `legacy` — that guard protects tenant 1, ADR-006). String values are emitted via * `yaml.stringify`, so any special characters in name/city are safely escaped (no * YAML injection from free-text input). + * + * Returns the modules it had to DEFER alongside the block, rather than only the block: + * a caller that proposes this entry without saying what was stripped has silently sold + * a module and shipped an entry omitting it. Making the strip part of the return value + * is what stops the next caller doing that by omission. */ -export function buildTenantRegistryEntry(input: TenantProvisionInput): string { +export function buildTenantRegistryEntry(input: TenantProvisionInput): { + entry: string; + deferred: string[]; +} { const { slug } = input; const box = input.box ?? "staging"; + const stripeAccount = input.stripeAccount?.trim(); + const { granted, deferred } = splitDeferredModules(input.modules, stripeAccount); const entry = { [slug]: { name: input.name, @@ -59,129 +120,22 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): string { frontend_tag: `tenant-${slug}`, currency: input.currency, languages: input.languages, - modules: input.modules, + // NOT `input.modules` — see ACCOUNT_PAIRED_MODULE_IDS. + modules: granted, template: input.template, admin_email: input.adminEmail, + // Emitted only when there is one, and always together with the module that needs + // it — the two are written from the same `granted`/`stripeAccount` pair so the + // entry can never carry one half of the guard's condition. + ...(stripeAccount ? { stripe_account: stripeAccount } : {}), // Only emit city when set — the registry field is optional. ...(input.city ? { city: input.city } : {}), }, }; - return stringify(entry) + const block = stringify(entry) .trimEnd() .split("\n") .map((line) => (line ? ` ${line}` : line)) .join("\n"); -} - -// Close the quote, emit an escaped apostrophe, reopen: the only way to get a -// literal ' inside a POSIX single-quoted argument. -const SHELL_QUOTED_APOSTROPHE = String.raw`'\''`; - -/** Quote a value for a POSIX shell single-quoted argument. The tenant name is - * free text and the founder copy-pastes these commands into a terminal, so an - * apostrophe must not end the quoting. */ -const shq = (value: string): string => - "'" + value.replaceAll("'", SHELL_QUOTED_APOSTROPHE) + "'"; - -/** Collapse anything that would break the markdown fence or the shell command this - * body embeds. `provisionSchema` already refuses control characters in `name`, so in - * practice this changes nothing — it is here so the function is safe on its own, - * because a body builder that depends on a caller's validation is one refactor away - * from emitting an unbalanced code fence built from public-form input. */ -const oneLine = (value: string): string => value.replace(/\s+/g, " ").trim(); - -/** - * The PR body for a provisioning proposal. - * - * **For a staging-box tenant, merging this PR provisions it** (SOFRA-ONBOARDING-PLAN §2 - * option B, ADR-012 amendment 2026-07-30): the deploy repo's - * `provision-on-registry-merge.yml` chains the image build and `provision-tenant.sh` off - * the registry sync. So the body leads with what to CHECK before merging — the merge is - * the last reversible moment. - * - * That chain is **staging-only** (it follows `sync-registry-to-staging.yml` and inherits - * its narrowness), so a `box: prod` entry gets the opposite header: merging does nothing - * and the commands are required, not a fallback. Telling a prod entry "merging provisions - * this" would leave the founder waiting on a chain that never runs. - * - * The image-build command stays in the body either way, because that step is the one that - * is easy to skip and fatal to skip: `NEXT_PUBLIC_*` are baked per domain, so provisioning - * without it dies at `docker compose pull` on an image that was never published. - */ -export function buildProvisioningPrBody(input: TenantProvisionInput): string { - const { slug } = input; - const domain = `${slug}.sofrapiwas.com`; - const box = input.box ?? "staging"; - const chained = box === "staging"; - - // One line, naming the one field in the diff the founder may need to change. It used to - // branch on the box and warn that a staging-box tenant rides develop; the generator no - // longer produces that entry, so warning about it would be an unfalsifiable checkbox. - const tagCheck = - "- [ ] **`backend_tag: latest`** — released code, published only from `main`. If this is a develop-tracking **showcase** rather than a customer, change it to `staging` in Files changed before merging; a customer should stay on `latest`, so their database is never migrated by unreleased code"; - - const header = chained - ? [ - "### ⚠️ Merging this PR provisions the tenant", - "", - "`provision-on-registry-merge.yml` builds the per-tenant frontend image and then runs", - "`provision-tenant.sh` on the box — roughly 15 minutes, hands-off. **This is the human", - "checkpoint, and it is the last reversible moment.** Before you merge:", - ] - : [ - `### Merging this PR does **not** provision — \`box: ${box}\``, - "", - "The post-merge chain is staging-only. This entry will be reported and skipped, so the", - "two commands below are **required**, not a fallback. Still check the entry first:", - ]; - - const after = chained - ? [ - "The chain provisions **first-time only**, and reports back on this PR when it is done —", - "or opens an issue on the deploy repo if any stage fails, including the registry sync it", - "waits on. A tenant it has already finished is skipped, so re-merging or", - "reverting-and-remerging this PR will not provision twice. One it left part-way through is", - "*completed* rather than skipped, so a retry is always safe.", - ] - : [ - "Merging still fires `sync-registry-to-staging.yml`, which copies the registry to the", - "**staging** box only. A prod-box tenant needs the prod box's own access (ADR-012", - "per-box boundary), so run the commands from a machine that has it.", - ]; - - return [ - `Adds the \`${slug}\` tenant to \`tenants/registry.yml\`, proposed by the control plane (sofra ADR-012).`, - "", - `- **domain** \`${domain}\` · **template** \`${input.template}\` · **currency** \`${input.currency}\``, - `- **languages** \`${input.languages.join(", ")}\` · **modules** \`${input.modules.join(", ")}\``, - `- **box** \`${box}\` · status starts at \`provisioning\``, - "", - ...header, - "", - `- [ ] the **slug** \`${slug}\` is what the customer should live on forever — it is the subdomain, database, role and compose project, and changing it later is a full re-provision`, - `- [ ] **modules** \`${input.modules.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`, - tagCheck, - `- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`, - "", - ...after, - "", - `Afterwards: \`./verify-env.sh https://${domain}\`, hand over the generated admin password from the tenant \`.env\` (and have them change it), then flip this entry's \`status\` to \`active\` in a follow-up commit.`, - "", - chained ? "### If the chain fails" : "### Run these after merging", - "", - "Both are idempotent and safe to re-run:", - "", - "```bash", - "gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend \\", - ` -f tenant_domain=${domain} \\`, - ` -f image_tag=tenant-${slug} \\`, - ` -f restaurant_name=${shq(oneLine(input.name))} \\`, - ` -f template=${input.template} \\`, - ` -f currency=${input.currency}`, - "", - `gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`, - "```", - "", - "Full runbook: deploy repo `DEPLOYMENT.md` §Tenant provisioning.", - ].join("\n"); + return { entry: block, deferred }; } diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 2ace22c..8348f58 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -6,11 +6,8 @@ // repo-scoped GitHub token (PROVISION_GITHUB_TOKEN) — never the box SSH key // (invariant 2). -import { - buildProvisioningPrBody, - buildTenantRegistryEntry, - type TenantProvisionInput, -} from "@/lib/provisioning-registry"; +import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; +import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry"; const OWNER = "piwas-21"; const REPO = "restaurant-app-deploy"; @@ -70,7 +67,9 @@ async function gh(token: string, path: string, init?: RequestInit): Promise { +export async function openProvisioningPr( + input: TenantProvisionInput, +): Promise<{ prUrl: string; deferred: string[] }> { const token = process.env.PROVISION_GITHUB_TOKEN; if (!token) throw new ProvisioningNotConfiguredError(); @@ -83,7 +82,11 @@ export async function openProvisioningPr(input: TenantProvisionInput): Promise<{ throw new ProvisioningApiError(`registry already has a '${input.slug}' entry`); } - const entry = buildTenantRegistryEntry(input); + // `deferred` is returned to the caller rather than only rendered into the PR body: a + // deferral means a customer is being BILLED for a module their tenant will not have + // until a second registry PR lands, and a prose section in one PR is not a record + // anyone can query later. The callers put it in the audit trail. + const { entry, deferred } = buildTenantRegistryEntry(input); // trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add // exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the // new tenant from butting against the previous one's trailing comment, which @@ -132,5 +135,5 @@ export async function openProvisioningPr(input: TenantProvisionInput): Promise<{ body: buildProvisioningPrBody(input), }), }); - return { prUrl: pr.html_url }; + return { prUrl: pr.html_url, deferred }; } diff --git a/lib/validation.ts b/lib/validation.ts index 1cef40a..304ec4d 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -170,4 +170,16 @@ export const provisionSchema = z.object({ message: `unknown module — allowed: ${MODULE_IDS.join(", ")}`, }), city: z.string().trim().max(200).optional().or(z.literal("")), + // The tenant's Stripe connected account, when the founder already has it (runbook + // §2b creates it BEFORE proposing, precisely because provision-tenant.sh refuses the + // module without it). Optional: left empty, the generator defers `online-payments` to + // a second registry PR rather than emitting an entry that would be refused. + // Grammar-pinned rather than free text — this value reaches the tenant's Stripe env, + // where a typo means charges addressed to an account that does not exist. + stripeAccount: z + .string() + .trim() + .regex(/^acct_[A-Za-z0-9]{8,32}$/, "Stripe account id, e.g. acct_1AbCdEfGhIjKlMnO") + .optional() + .or(z.literal("")), }); diff --git a/messages/ar.json b/messages/ar.json index 412851b..c3341eb 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -739,6 +739,8 @@ "currency": "العملة (مثل EUR)", "languages": "اللغات", "modules": "الوحدات", + "stripeAccount": "حساب Stripe (acct_…، اختياري)", + "stripeAccountHint": "فقط إذا اشتروا online-payments وكنت قد أنشأت حسابهم المرتبط بالفعل (دليل التشغيل §2b). إذا تُرك فارغًا، يُؤجَّل استخدام الوحدة ويشرح طلب الدمج كيفية إضافتها لاحقًا — إذ يرفض التزويد الوحدة بدون حساب.", "create": "فتح طلب سحب للسجل", "creating": "جارٍ الفتح…", "created": "تم فتح طلب سحب للسجل — راجعه وادمجه، ثم زوّد:", diff --git a/messages/de.json b/messages/de.json index 7af3cf2..e4a1d47 100644 --- a/messages/de.json +++ b/messages/de.json @@ -739,6 +739,8 @@ "currency": "Währung (z. B. EUR)", "languages": "Sprachen", "modules": "Module", + "stripeAccount": "Stripe-Konto (acct_…, optional)", + "stripeAccountHint": "Nur wenn online-payments gekauft wurde UND das verbundene Konto bereits angelegt ist (Runbook §2b). Leer gelassen, wird das Modul zurückgestellt und die PR erklärt, wie es später ergänzt wird — die Provisionierung lehnt das Modul ohne Konto ab.", "create": "Registry-PR öffnen", "creating": "Wird geöffnet…", "created": "Registry-PR geöffnet — prüfen + zusammenführen, dann bereitstellen:", diff --git a/messages/en.json b/messages/en.json index 471b843..ad8cc3b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -739,6 +739,8 @@ "currency": "Currency (e.g. EUR)", "languages": "Languages", "modules": "Modules", + "stripeAccount": "Stripe account (acct_…, optional)", + "stripeAccountHint": "Only if they bought online-payments AND you already created their connected account (runbook §2b). Left empty, the module is held back and the PR says how to add it later — provisioning refuses the module without an account.", "create": "Open registry PR", "creating": "Opening…", "created": "Registry PR opened — review + merge it, then provision:", diff --git a/messages/fr.json b/messages/fr.json index 75a6f71..b65b65f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -739,6 +739,8 @@ "currency": "Devise (ex. EUR)", "languages": "Langues", "modules": "Modules", + "stripeAccount": "Compte Stripe (acct_…, facultatif)", + "stripeAccountHint": "Uniquement s’ils ont acheté online-payments ET que vous avez déjà créé leur compte connecté (runbook §2b). Laissé vide, le module est mis de côté et la PR explique comment l’ajouter ensuite — le provisionnement refuse le module sans compte.", "create": "Ouvrir la PR de registre", "creating": "Ouverture…", "created": "PR de registre ouverte — révisez + fusionnez, puis provisionnez :", diff --git a/messages/nl.json b/messages/nl.json index 7e52729..f5b29cd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -739,6 +739,8 @@ "currency": "Valuta (bijv. EUR)", "languages": "Talen", "modules": "Modules", + "stripeAccount": "Stripe-account (acct_…, optioneel)", + "stripeAccountHint": "Alleen als ze online-payments hebben gekocht ÉN je hun gekoppelde account al hebt aangemaakt (runbook §2b). Leeg gelaten wordt de module achtergehouden en legt de PR uit hoe je die later toevoegt — provisioning weigert de module zonder account.", "create": "Registry-PR openen", "creating": "Bezig met openen…", "created": "Registry-PR geopend — beoordeel + voeg samen, richt dan in:", diff --git a/messages/tr.json b/messages/tr.json index a099fbf..cf5e462 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -739,6 +739,8 @@ "currency": "Para birimi (ör. EUR)", "languages": "Diller", "modules": "Modüller", + "stripeAccount": "Stripe hesabı (acct_…, isteğe bağlı)", + "stripeAccountHint": "Yalnızca online-payments satın aldılarsa VE bağlı hesaplarını zaten oluşturduysanız (runbook §2b). Boş bırakılırsa modül beklemeye alınır ve PR sonradan nasıl ekleneceğini anlatır — hesap olmadan provisioning modülü reddeder.", "create": "Kayıt PR’si aç", "creating": "Açılıyor…", "created": "Kayıt PR’si açıldı — inceleyip birleştirin, sonra sağlayın:", diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index 7491748..713dbba 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -1,10 +1,16 @@ +import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; -import { buildProvisioningPrBody, buildTenantRegistryEntry } from "@/lib/provisioning-registry"; +import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; +import { buildTenantRegistryEntry, splitDeferredModules } from "@/lib/provisioning-registry"; -// Parse a generated block back through the registry shape it will live in. -const asTenant = (block: string, slug: string) => - (parse(`version: 1\ntenants:\n${block}`) as { tenants: Record }).tenants[slug]; +// Parse a generated block back through the registry shape it will live in. Takes the +// builder's whole result, not just the block, so every assertion below reads the YAML +// that was actually emitted rather than a hand-copied string. +const asTenant = (built: { entry: string }, slug: string) => + (parse(`version: 1\ntenants:\n${built.entry}`) as { tenants: Record }).tenants[ + slug + ]; describe("buildTenantRegistryEntry", () => { it("derives slug-based fields and defaults status/managed/box", () => { @@ -181,3 +187,204 @@ describe("buildProvisioningPrBody", () => { expect(body).not.toContain("-f restaurant_name=Chez"); }); }); + +// P1 — a purchased `online-payments` must never reach the proposed entry. +// +// The failure this prevents is not "the tenant provisions without card payment". The +// guard below runs BEFORE the database, the compose project and the image, and exits 1, +// so the tenant would get no restaurant at all while a paid customer waits. +describe("deferring online-payments out of the generated entry", () => { + const base = { + slug: "bistro-nova", + name: "Bistro Nova", + adminEmail: "owner@nova.example", + template: "craft" as const, + currency: "EUR", + languages: ["en", "nl"], + city: "Rotterdam", + }; + const bought = ["core", "reservations", "online-payments"]; + + /** + * The refusal from `provision-tenant.sh` (deploy repo, the `online-payments` guard), + * verbatim, evaluated by a real bash. Copied text rather than an import because that + * script lives in another repo and is not on disk in this one — so the test pins the + * generator against the guard's OWN condition instead of against a paraphrase of it. + * If the guard is ever reworded, this string is what has to be re-copied. + */ + const provisionRefuses = (modulesCsv: string, stripeAccount: string): boolean => { + const script = ` + REG_MODULES=$1 + REG_STRIPE_ACCOUNT=$2 + if [[ " \${REG_MODULES//,/ } " == *" online-payments "* && -z "$REG_STRIPE_ACCOUNT" ]]; then + exit 0 + fi + exit 1 + `; + const res = spawnSync("bash", ["-c", script, "bash", modulesCsv, stripeAccount]); + if (res.error) throw res.error; + // 0 = the guard fired (refused), 1 = it did not. Anything else means the harness + // itself broke, and a broken harness must not read as "the guard stayed silent". + if (res.status !== 0 && res.status !== 1) { + throw new Error(`guard harness failed: status=${res.status} ${res.stderr}`); + } + return res.status === 0; + }; + + // Fields exactly as provision-tenant.sh would read them out of the merged registry. + const asRegistryFields = (t: Record) => ({ + modulesCsv: (t.modules as string[]).join(","), + stripeAccount: (t.stripe_account as string | undefined) ?? "", + }); + + it("emits an entry the guard ACCEPTS, where the pre-P1 entry was refused", () => { + const t = asTenant( + buildTenantRegistryEntry({ ...base, modules: bought }), + base.slug, + ) as Record; + const { modulesCsv, stripeAccount } = asRegistryFields(t); + + // The generator has never emitted stripe_account, so the guard's second conjunct is + // satisfied either way — which is precisely why the module list is the whole story. + expect(stripeAccount).toBe(""); + + // Two inputs, two answers, from the guard's own text. + expect(provisionRefuses(modulesCsv, stripeAccount)).toBe(false); + // Pre-P1 the entry carried `modules: input.modules` verbatim. Same guard, same + // account, same tenant — refused. + expect(provisionRefuses(bought.join(","), stripeAccount)).toBe(true); + }); + + it("grants the module in ONE shot when the account is already known", () => { + // The founder path. `signup-to-live-tenant.md` §2b has them create the connected + // account BEFORE proposing, precisely because of the guard above — so deferring + // unconditionally would make that documented order pointless and route them into a + // second PR they do not need. + const { entry, deferred } = buildTenantRegistryEntry({ + ...base, + modules: bought, + stripeAccount: "acct_1AbCdEfGhIjKlMnO", + }); + const t = asTenant({ entry }, base.slug) as Record; + expect(t.modules).toEqual(bought); + expect(t.stripe_account).toBe("acct_1AbCdEfGhIjKlMnO"); + expect(deferred).toEqual([]); + + // Both halves present, so the same guard that refuses the pre-P1 entry accepts this. + const { modulesCsv, stripeAccount } = asRegistryFields(t); + expect(provisionRefuses(modulesCsv, stripeAccount)).toBe(false); + + // ...and the body must not then warn about a deferral that did not happen. + const body = buildProvisioningPrBody({ + ...base, + modules: bought, + stripeAccount: "acct_1AbCdEfGhIjKlMnO", + }); + expect(body).not.toContain("Bought but deliberately NOT in this entry"); + }); + + it("treats a whitespace-only account as no account", () => { + // `provision-tenant.sh` tests `-z "$REG_STRIPE_ACCOUNT"`, which a single space does + // NOT satisfy — so emitting " " would sail past this generator and then hand the box + // an entry whose Stripe env points at nothing. Deferring is the safe reading. + const { entry, deferred } = buildTenantRegistryEntry({ + ...base, + modules: bought, + stripeAccount: " ", + }); + const t = asTenant({ entry }, base.slug) as Record; + expect(deferred).toEqual(["online-payments"]); + expect(t.stripe_account).toBeUndefined(); + expect(t.modules).toEqual(["core", "reservations"]); + }); + + it("never emits one half of the guard's condition", () => { + // The property that actually matters, over both shapes: an entry carries the module + // if and only if it carries an account. Anything else is the landmine, in one + // direction or the other. + for (const stripeAccount of [undefined, "", " ", "acct_1AbCdEfGhIjKlMnO"]) { + const t = asTenant( + buildTenantRegistryEntry({ ...base, modules: bought, stripeAccount }), + base.slug, + ) as Record; + const { modulesCsv, stripeAccount: emitted } = asRegistryFields(t); + expect(provisionRefuses(modulesCsv, emitted)).toBe(false); + } + }); + + it("keeps every other purchased module, in order", () => { + const { entry, deferred } = buildTenantRegistryEntry({ ...base, modules: bought }); + const t = asTenant({ entry }, base.slug) as Record; + expect(t.modules).toEqual(["core", "reservations"]); + expect(deferred).toEqual(["online-payments"]); + }); + + it("changes nothing for a tenant that did not buy it", () => { + // The strip must be surgical: a generator that quietly dropped ids would be the same + // class of bug pointed the other way. + const { entry, deferred } = buildTenantRegistryEntry({ + ...base, + modules: ["core", "reservations", "loyalty", "printing"], + }); + const t = asTenant({ entry }, base.slug) as Record; + expect(t.modules).toEqual(["core", "reservations", "loyalty", "printing"]); + expect(deferred).toEqual([]); + expect(splitDeferredModules(["core"]).granted).toEqual(["core"]); + }); + + it("makes the PR body name the purchase, the reason and the exact follow-up", () => { + const body = buildProvisioningPrBody({ ...base, modules: bought }); + + // Named as bought, not silently absent. + expect(body).toContain("Bought but deliberately NOT in this entry: `online-payments`"); + expect(body).toContain("no restaurant at all"); + // The reason the founder cannot just add it now. + expect(body).toContain("only the restaurant can create it"); + // The follow-up, with BOTH halves — an entry adding one without the other is the + // same landmine re-armed by hand. + expect(body).toContain("stripe_account: acct_"); + expect(body).toContain("modules: [core, reservations, online-payments]"); + expect(body).toContain("gh workflow run provision-tenant.yml"); + + // And the checklist must not still ask the founder to confirm the list matches the + // receipt — with a deferral that is false by construction. + expect(body).not.toContain( + "`core, reservations` match what they actually paid for", + ); + expect(body).toContain("EXCEPT `online-payments`, which is held back on purpose"); + }); + + it("says none of it when nothing was deferred", () => { + // A standing warning about a module nobody bought trains the founder to skim past + // the one that matters. + const body = buildProvisioningPrBody({ ...base, modules: ["core", "reservations"] }); + expect(body).not.toContain("Bought but deliberately NOT in this entry"); + expect(body).not.toContain("stripe_account"); + expect(body).toContain("`core, reservations` match what they actually paid for"); + }); + + it("keeps the markdown fences balanced once the block is present", () => { + // The deferral block adds a SECOND fenced snippet to a body that already embeds the + // tenant name in a shell fence. The existing fence test runs on a no-deferral input, + // so without this one the four-fence layout is never exercised at all. + const body = buildProvisioningPrBody({ + ...base, + modules: bought, + name: "Bistro\n```\n## PWNED", + }); + expect( + body.split("\n").filter((l) => l.trimStart().startsWith("```")), + ).toEqual(["```yaml", "```", "```bash", "```"]); + }); + + it("describes the same module list the entry actually carries", () => { + // The body and the entry are built by separate functions. Read the modules out of + // the generated YAML and require the body's summary line to quote that exact list, + // so the two cannot drift into describing different diffs. + const input = { ...base, modules: bought }; + const t = asTenant(buildTenantRegistryEntry(input), base.slug) as Record; + expect(buildProvisioningPrBody(input)).toContain( + `**modules** \`${(t.modules as string[]).join(", ")}\``, + ); + }); +}); diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index d00b1dc..3647d60 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -293,6 +293,24 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => { expect(provisionSchema.safeParse({ ...base, modules: "" }).success).toBe(false); }); + it("accepts a Stripe account id, and treats absent/empty as 'not supplied'", () => { + // Optional by design: the self-serve path never has one, and the founder path only + // sometimes does. Empty must parse, because empty is the signal that makes the + // generator hold `online-payments` back rather than propose a refused entry. + expect(provisionSchema.safeParse({ ...base, stripeAccount: "acct_1AbCdEfGhIjKlMnO" }).success).toBe(true); + expect(provisionSchema.safeParse({ ...base, stripeAccount: "" }).success).toBe(true); + expect(provisionSchema.safeParse(base).success).toBe(true); + }); + + it("rejects a malformed Stripe account id rather than forwarding it", () => { + // This value reaches the tenant's Stripe env. A typo there is not a validation + // nicety: charges would be addressed to an account that does not exist, and the + // registry guard only checks that the field is NON-EMPTY, never that it is real. + for (const bad of ["acct", "acct_", "1AbCdEfGhIjKlMnO", "acct_short", "acct_has spaces"]) { + expect(provisionSchema.safeParse({ ...base, stripeAccount: bad }).success).toBe(false); + } + }); + it("rejects a line break inside the tenant name", () => { // `trim()` only strips the ENDS, so an interior newline used to survive — and the // name is forwarded into build-tenant-image.yml's `build-args:`, which is a diff --git a/vitest.config.ts b/vitest.config.ts index 9b51351..1647310 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,11 @@ export default defineConfig({ "lib/provision-prefill.ts", "lib/slug-availability.ts", "lib/provisioning-registry.ts", + // Split out of provisioning-registry.ts (P1) when the pair outgrew the LOC limit. + // Listed explicitly because this include list is explicit: leaving it off would + // have quietly moved already-covered code out of the floor's scope, which reads + // as a passing gate rather than as lost coverage. + "lib/provisioning-pr-body.ts", "lib/module-catalog.ts", "lib/tenant-options.ts", "lib/signup-configuration.ts",