From 120532e63f212357d334f3885901f062abfb0595 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 17 May 2026 15:08:29 +0200 Subject: [PATCH] fix(loyalty): ProgramCreate accepts null for minimum_purchase_cents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin program-edit form sends null for empty number inputs. ProgramCreate had minimum_purchase_cents declared as int (default 0, ge=0), which rejected null with 422 — even though the DB column is NOT NULL with default 0 and "0 means no minimum" is the documented semantics. Add a field_validator(mode="before") that coerces None to 0 so the admin form (and any other client that sends null for an empty optional number) goes through cleanly. The other tolerant fields in the schema (stamps_reward_value_cents, points_expiration_days) are already int | None; ProgramUpdate already accepts null here. User hit this after a clean-DB reset prevented falling back to a pre-existing program; the merchant area form happens to send 0 instead of null, masking the bug there. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/modules/loyalty/schemas/program.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py index b8bab56f..ac1e027f 100644 --- a/app/modules/loyalty/schemas/program.py +++ b/app/modules/loyalty/schemas/program.py @@ -10,7 +10,7 @@ Merchant-based programs: from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class PointsRewardConfig(BaseModel): @@ -83,6 +83,13 @@ class ProgramCreate(BaseModel): description="Minimum purchase amount to earn points (0 = no minimum)", ) + @field_validator("minimum_purchase_cents", mode="before") + @classmethod + def _coerce_purchase_cents_none_to_zero(cls, v): + # Form sends null for empty number inputs; DB column is NOT NULL + # with default 0, and 0 already means "no minimum" semantically. + return 0 if v is None else v + # Future: Tier configuration tier_config: list[TierConfig] | None = Field( None,