diff --git a/.env.example b/.env.example index 75a6e04..ff6ce24 100644 --- a/.env.example +++ b/.env.example @@ -57,8 +57,9 @@ PCM_API_SECRET= # Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=). PCM_WEBHOOK_SECRETS= # PCM_WEBHOOK_SECRET= -# Return address on every postcard order (JSON). Example: -# PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"} +# Required return address on every postcard order. +# Quote the JSON (compose/shell break on bare {…, …}). +# PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}' PCM_RETURN_ADDRESS= # Or set fields individually if JSON is empty: # PCM_RETURN_LINE1= diff --git a/.env.prod.example b/.env.prod.example index ff09205..6899694 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -66,8 +66,8 @@ PCM_API_SECRET=replace-me PCM_WEBHOOK_SECRETS=replace-me,replace-me-2 # PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias -# JSON return address for postcard orders -PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"} +# JSON return address for postcard orders (quote so compose keeps commas) +PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"}' # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob # CLICK2MAIL_API_KEY= # POSTGRID_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 44b710b..23a35bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,19 @@ services: EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend} SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-} SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-} + POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm} + PCM_API_KEY: ${PCM_API_KEY:-} + PCM_API_SECRET: ${PCM_API_SECRET:-} + PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-} + PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-} + PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-} + # Quote JSON in `.env`: PCM_RETURN_ADDRESS='{"firstName":"…",...}' + PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-} + PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-} + PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-} + PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} + PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} + PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} depends_on: db: condition: service_healthy @@ -70,6 +83,18 @@ services: EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend} SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-} SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-} + POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm} + PCM_API_KEY: ${PCM_API_KEY:-} + PCM_API_SECRET: ${PCM_API_SECRET:-} + PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-} + PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-} + PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-} + PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-} + PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-} + PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-} + PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} + PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} + PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} depends_on: db: condition: service_healthy diff --git a/site/messaging/providers/postcard/pcm.py b/site/messaging/providers/postcard/pcm.py index cb5c76c..097258b 100644 --- a/site/messaging/providers/postcard/pcm.py +++ b/site/messaging/providers/postcard/pcm.py @@ -193,38 +193,82 @@ def pcm_request( def return_address_from_settings() -> dict[str, str]: - """Build PCM returnAddress from PCM_RETURN_ADDRESS JSON or CONTACT_* vars.""" + """Build PCM returnAddress from PCM_RETURN_ADDRESS JSON and/or PCM_RETURN_* vars. + + PCM requires address/city/state (and zip) whenever firstName is set — an empty + street with SITE_NAME-derived firstName causes a 400 from POST /order/postcard. + """ + data: dict[str, Any] = {} raw = (settings.PCM_RETURN_ADDRESS or "").strip() if raw: - data = json.loads(raw) - if not isinstance(data, dict): + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise PcmApiError( + "PCM_RETURN_ADDRESS must be valid JSON " + '(e.g. {"firstName":"…","lastName":"…","address":"…",' + '"city":"…","state":"IL","zipCode":"…"})' + ) from exc + if not isinstance(parsed, dict): raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object") - return { - "company": str(data.get("company") or ""), - "firstName": str(data.get("firstName") or data.get("first_name") or ""), - "lastName": str(data.get("lastName") or data.get("last_name") or ""), - "address": str(data.get("address") or data.get("line1") or ""), - "address2": str(data.get("address2") or data.get("line2") or ""), - "city": str(data.get("city") or ""), - "state": str(data.get("state") or ""), - "zipCode": str(data.get("zipCode") or data.get("zip") or ""), - } + data = parsed name = (settings.SITE_NAME or "").strip() parts = name.split(None, 1) - first = parts[0] if parts else "Monica" - last = parts[1] if len(parts) > 1 else "" - return { - "company": "", - "firstName": first, - "lastName": last, - "address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""), - "address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""), - "city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""), - "state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""), - "zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") or ""), + site_first = parts[0] if parts else "" + site_last = parts[1] if len(parts) > 1 else "" + + address = { + "company": str(data.get("company") or "").strip(), + "firstName": str( + data.get("firstName") or data.get("first_name") or site_first or "" + ).strip(), + "lastName": str( + data.get("lastName") or data.get("last_name") or site_last or "" + ).strip(), + "address": str( + data.get("address") + or data.get("line1") + or getattr(settings, "PCM_RETURN_LINE1", "") + or "" + ).strip(), + # PCM treats blank address2 oddly; space matches recipient payload. + "address2": str( + data.get("address2") + or data.get("line2") + or getattr(settings, "PCM_RETURN_LINE2", "") + or "" + ).strip() + or " ", + "city": str( + data.get("city") or getattr(settings, "PCM_RETURN_CITY", "") or "" + ).strip(), + "state": str( + data.get("state") or getattr(settings, "PCM_RETURN_STATE", "") or "" + ).strip(), + "zipCode": str( + data.get("zipCode") + or data.get("zip") + or getattr(settings, "PCM_RETURN_ZIP", "") + or "" + ).strip(), } + missing = [ + field + for field in ("firstName", "address", "city", "state", "zipCode") + if not address.get(field, "").strip() + ] + if missing: + raise PcmApiError( + "PCM return address incomplete (missing " + + ", ".join(missing) + + "). Set PCM_RETURN_ADDRESS JSON with firstName, lastName, " + "address, city, state, zipCode " + "(or PCM_RETURN_LINE1 / CITY / STATE / ZIP)." + ) + return address + def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]: address = contact.postal_address or {} diff --git a/site/messaging/tests.py b/site/messaging/tests.py index 4673970..1cd18fd 100644 --- a/site/messaging/tests.py +++ b/site/messaging/tests.py @@ -1079,6 +1079,34 @@ class PcmAuthTests(TestCase): pcm_mod.login(force=True) self.assertIn("PCM_API_SECRET", str(ctx.exception)) + def test_return_address_requires_street_fields(self): + from messaging.providers.postcard import pcm as pcm_mod + + with self.settings( + SITE_NAME="Monica Dhillon", + PCM_RETURN_ADDRESS="", + PCM_RETURN_LINE1="", + PCM_RETURN_CITY="", + PCM_RETURN_STATE="", + PCM_RETURN_ZIP="", + ): + with self.assertRaises(pcm_mod.PcmApiError) as ctx: + pcm_mod.return_address_from_settings() + self.assertIn("PCM return address incomplete", str(ctx.exception)) + + def test_return_address_from_json(self): + from messaging.providers.postcard import pcm as pcm_mod + + with self.settings( + PCM_RETURN_ADDRESS=( + '{"firstName":"Mo","lastName":"D","address":"1 Main",' + '"city":"Naperville","state":"IL","zipCode":"60540"}' + ), + ): + addr = pcm_mod.return_address_from_settings() + self.assertEqual(addr["address"], "1 Main") + self.assertEqual(addr["zipCode"], "60540") + class PostcardAddressDefaultConsentTests(TestCase): def test_address_without_consent_is_postcard_eligible(self):