Require a complete PCM return address before placing orders.
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m40s

Empty PCM_RETURN_ADDRESS was sending SITE_NAME as firstName with blank street fields, which PCM rejects with 400; validate early and wire PCM env through compose.
This commit is contained in:
2026-08-11 05:52:39 -05:00
parent 7fb5317308
commit 2643ba7a96
5 changed files with 126 additions and 28 deletions
+3 -2
View File
@@ -57,8 +57,9 @@ PCM_API_SECRET=
# Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=). # Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=).
PCM_WEBHOOK_SECRETS= PCM_WEBHOOK_SECRETS=
# PCM_WEBHOOK_SECRET= # PCM_WEBHOOK_SECRET=
# Return address on every postcard order (JSON). Example: # Required return address on every postcard order.
# PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"} # 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= PCM_RETURN_ADDRESS=
# Or set fields individually if JSON is empty: # Or set fields individually if JSON is empty:
# PCM_RETURN_LINE1= # PCM_RETURN_LINE1=
+2 -2
View File
@@ -66,8 +66,8 @@ PCM_API_SECRET=replace-me
PCM_WEBHOOK_SECRETS=replace-me,replace-me-2 PCM_WEBHOOK_SECRETS=replace-me,replace-me-2
# PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias # PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias
# JSON return address for postcard orders # 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"} 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 # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob
# CLICK2MAIL_API_KEY= # CLICK2MAIL_API_KEY=
# POSTGRID_API_KEY= # POSTGRID_API_KEY=
+25
View File
@@ -45,6 +45,19 @@ services:
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend} EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-} SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-} 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -70,6 +83,18 @@ services:
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend} EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-} SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-} 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+68 -24
View File
@@ -193,38 +193,82 @@ def pcm_request(
def return_address_from_settings() -> dict[str, str]: 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() raw = (settings.PCM_RETURN_ADDRESS or "").strip()
if raw: if raw:
data = json.loads(raw) try:
if not isinstance(data, dict): 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") raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
return { data = parsed
"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 ""),
}
name = (settings.SITE_NAME or "").strip() name = (settings.SITE_NAME or "").strip()
parts = name.split(None, 1) parts = name.split(None, 1)
first = parts[0] if parts else "Monica" site_first = parts[0] if parts else ""
last = parts[1] if len(parts) > 1 else "" site_last = parts[1] if len(parts) > 1 else ""
return {
"company": "", address = {
"firstName": first, "company": str(data.get("company") or "").strip(),
"lastName": last, "firstName": str(
"address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""), data.get("firstName") or data.get("first_name") or site_first or ""
"address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""), ).strip(),
"city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""), "lastName": str(
"state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""), data.get("lastName") or data.get("last_name") or site_last or ""
"zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") 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]: def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]:
address = contact.postal_address or {} address = contact.postal_address or {}
+28
View File
@@ -1079,6 +1079,34 @@ class PcmAuthTests(TestCase):
pcm_mod.login(force=True) pcm_mod.login(force=True)
self.assertIn("PCM_API_SECRET", str(ctx.exception)) 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): class PostcardAddressDefaultConsentTests(TestCase):
def test_address_without_consent_is_postcard_eligible(self): def test_address_without_consent_is_postcard_eligible(self):