Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/SignupConfigurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function SignupConfigurator() {
const saving = quote.aLaCarteCents - quote.monthlyCents;

const optionalModules = MODULES.filter(
(m) => m.id !== "core" && m.id !== "extra-languages",
(m) => m.id !== "core" && m.id !== "extra-languages" && m.sellable !== false,
);

return (
Expand Down
2 changes: 1 addition & 1 deletion components/control/ProvisionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default function ProvisionPicker({
{labels.modulesCore} · {eur(MODULES.find((m) => m.id === "core")!.priceCents)}
</p>
<div className="grid gap-1 sm:grid-cols-2">
{MODULES.filter((m) => m.id !== "core" && m.id !== "extra-languages").map((m) => (
{MODULES.filter((m) => m.id !== "core" && m.id !== "extra-languages" && m.sellable !== false).map((m) => (
<label key={m.id} className="flex items-start gap-2 font-label text-sm">
<input
type="checkbox"
Expand Down
21 changes: 21 additions & 0 deletions lib/module-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const MODULE_IDS = [
"reservations",
"loyalty",
"printing",
"online-payments",
"extra-languages",
] as const;

Expand All @@ -31,6 +32,16 @@ export interface CatalogModule {
priceCents: number;
/** The RUMI surface this unlocks — kept next to the price so the two never drift. */
surface: string;
/**
* Absent = sellable. `false` means the id is a VALID part of the registry vocabulary — a
* tenant entry carrying it provisions, and the backend recognises it — but it must not be
* offered for purchase yet, because the surface it unlocks is not finished. Selling a module
* nothing enforces is charging for a product that does not vary.
*
* Both purchase surfaces filter on this: the public SignupConfigurator and the founder's
* ProvisionPicker. Flip it (delete the line) in the slice that makes the module real.
*/
sellable?: boolean;
}

/**
Expand All @@ -46,6 +57,16 @@ export const MODULES: readonly CatalogModule[] = [
{ id: "reservations", priceCents: 900, surface: "/reservations + admin management" },
{ id: "loyalty", priceCents: 900, surface: "fidelity points, customer groups, discounts" },
{ id: "printing", priceCents: 900, surface: "printer-app companion + printer feed" },
{
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;

Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions tests/unit/module-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ 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.
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.
expect(isModuleId("online-payments")).toBe(true);
expect(MODULES.find((m) => m.id === "online-payments")?.sellable).toBe(false);
});

it("only bundles modules that exist, and always includes core", () => {
for (const b of BUNDLES) {
expect(unknownModuleIds([...b.modules])).toEqual([]);
Expand Down
Loading