* feat(billing+notification): wire voucher-issued email (D28)
D28 of the Sovereign DoD requires that issuing a voucher emails it to
the recipient zero-touch. Today POST /billing/vouchers/issue persists
the PromoCode row but never notifies anyone — so a gifted voucher only
reaches its recipient if the operator manually sends the code over a
side channel. This wires sme-billing -> sme-notification so the email
fires automatically on every successful upsert that carries a
recipient_email field.
Architecture follows the existing notification-service seam:
sme-billing POSTs to http://notification.sme.svc.cluster.local:8087/
notification/send with template=voucher-issued; sme-notification renders
the HTML and dispatches via Stalwart over SMTP. No direct SMTP code is
added to billing, no stalwart-mail calls bypass notification.
Server-side only — the owner-UI for issuing vouchers (D28b) is a
separate PR.
Changes:
notification/templates/templates.go
+ VoucherIssuedEmail(code, creditOMR, description, sovereignFQDN,
validityHint) — renders code prominently, redeem button to
https://marketplace.<sovereignFQDN>/redeem/?code=<CODE>; FQDN
always supplied by caller, NEVER hardcoded.
notification/handlers/handlers.go
+ renderTemplate("voucher-issued") case parsing
{code, credit_omr, description, sovereign_fqdn, validity_hint}.
+ Default subject "You've been gifted a voucher for OpenOva SME".
billing/handlers/handlers.go
+ Handler fields: NotificationURL, SovereignFQDN, NotificationClient.
billing/handlers/vouchers.go
+ issueVoucherRequest = store.PromoCode + RecipientEmail (request-
only; never persisted).
+ sendVoucherIssuedEmail() — POSTs to NotificationURL with a 5s
timeout. Best-effort: a non-2xx or transport error logs but does
NOT fail the IssueVoucher response, because the row is already
persisted and re-issuing the same code re-fires the email.
+ Re-issue semantics (#91 resurrects soft-deleted rows) extend to
the email path — documented in the handler comment.
billing/main.go
+ Reads NOTIFICATION_SERVICE_URL (default
http://notification.sme.svc.cluster.local:8087/notification/send)
and SOVEREIGN_FQDN env vars. Wires a 5s default http.Client.
products/catalyst/chart/templates/sme-services/billing.yaml
+ Pipes NOTIFICATION_SERVICE_URL (cluster-DNS constant) and
SOVEREIGN_FQDN (from .Values.global.sovereignFQDN, NEVER
hardcoded) into the billing Deployment.
Tests:
notification/handlers/handlers_test.go (new)
+ TestRenderTemplate_VoucherIssued: rendered HTML contains code +
credit + a redeem URL built from the supplied FQDN; never falls
back to marketplace.openova.io.
+ TestRenderTemplate_VoucherIssued_CustomSubject + _NoDescription
+ TestRenderTemplate_UnknownTemplate as guard rails.
billing/handlers/vouchers_test.go
+ TestIssueVoucher_SendsEmail_WhenRecipientPresent: a fake round-
tripper sees the POST to notification with the right URL +
template + data (code upper-cased, credit_omr, sovereign_fqdn,
description) when recipient_email is set.
+ TestIssueVoucher_NoEmail_WhenRecipientAbsent: no notification
call when recipient is empty.
+ TestIssueVoucher_NotificationFailure_DoesNotFailUpsert:
operator gets 200 even when notification returns 500.
+ TestIssueVoucher_403WithoutVoucherIssuerRole: role gate preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chart): admin pod uses dedicated image tag (D27 SME stack)
t132 caught admin pod stuck in ImagePullBackOff on `admin:b0ed216` —
the SME services CI run for that mono-repo SHA published 10 services
but admin's image was missing from GHCR. Decouple admin's tag from
smeTag so a missing-build for one service doesn't wedge the SME stack.
Default to `3c2f7e4` (matches marketplaceApi + console, known-published).
When admin's UI changes, bump in lockstep with those.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(slot-13): pin bp-catalyst-platform to 1.4.144
PR #1556 (D28 voucher email wire) + PR #1557 (D27 admin tag override)
landed and Blueprint Release packaged 1.4.144. Pin the slot file so
future provisions get the latest chart by default — t132 manually
upgraded via kubectl patch but t133+ will inherit it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(gateway): /redeem-preview + plans + addons must be public (D29)
The marketplace /redeem?code=XXX landing page calls
/api/billing/vouchers/redeem-preview unauthenticated per docs/FRANCHISE-
MODEL.md §3, but the gateway's catch-all /api/billing/ entry was
returning 401 to it — breaking the entire voucher-redeem zero-touch
flow that D29 depends on.
Also expose /api/billing/plans and /api/billing/addons so the
marketplace landing can render pricing without a session.
Caught live on t132 2026-05-16 — every /redeem call returned 401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>