39f3eb06ec
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
39f3eb06ec |
fix(billing): transactional voucher redemption — only decrement on order.placed success (Closes #2000)
t38 walk caught the canonical TBD-V9 bug: customer redeems voucher WALK-T38-2138 on a 50 OMR order, voucher credit is only 10 OMR, Stripe is unconfigured in the Sovereign, Checkout returns 503 "payment processor not configured" — but promo_codes.times_redeemed had already advanced 0→1, promo_redemptions row was inserted, and a credit_ledger grant was written. Voucher shows "Exhausted 1/1" with no order to show for it; the customer's one-per-customer promo is silently burned. Root cause: store.RedeemPromoCode runs its own transaction (necessary for the FOR UPDATE concurrency cap) and commits the three side effects up front. The rest of the Checkout pipeline (GetCreditBalance, GetSettings, CreditOnlyCheckout, Stripe customer + session creation) can fail without undoing the redemption. Fix (saga / compensating action): - store.RollbackPromoCodeRedemption(customerID, code) — single tx that DELETEs promo_redemptions, decrements times_redeemed (GREATEST(..,0) underflow guarded), and DELETEs the credit_ledger redeem grant (filter reason='promo:<code>' AND order_id IS NULL so order spend ledger rows are not touched). Idempotent: 0-row DELETE on promo_redemptions short-circuits the rest, so re-running a failed checkout never double-decrements. - handlers.Checkout tracks voucherRedeemed and calls RollbackPromoCodeRedemption on every downstream failure: settings load, Stripe-unconfigured 503 (the t38 walk path), CreateOrder failure, Stripe customer rejection, Stripe session rejection, plan-price unresolvable. - Voucher only stays committed once (a) CreditOnlyCheckout commits the order+spend+sub transactionally and order.placed fires, or (b) the Stripe Checkout Session URL is handed back to the customer (canonical abandoned-cart: credit persists on ledger for the next attempt). Tests: - store_test.go: three new tests cover the rollback contract — happy path (all three side effects undone in one tx), idempotent no-op when no redemption row exists, empty-args no-op (no DB hit). - checkout_test.go: TestCheckout_VoucherPartialCover_StripeUnconfigured_RollsBackRedemption is the t38 regression — full sqlmock walk asserting the rollback tx fires before the 503 response. bp-catalyst-platform Chart.yaml + bootstrap-kit pin bumped 1.4.214 → 1.4.215. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
50a45a9783
|
fix(billing): skip Stripe when voucher covers 100% of total (unblocks fully-paid voucher checkout) (#1628)
POST /billing/checkout was 503'ing with "payment processor is not
configured" on Sovereigns that have not pasted Stripe keys yet — even
when the customer's credit balance (from a fresh voucher redemption
in the same request, or a prior balance) fully covered the order
total. Make the credit-only short-circuit explicit: compute
`remainingOMR := totalOMR - creditBalance` and settle via
CreditOnlyCheckout when `<= 0`, BEFORE any Stripe settings probe.
This is the path that has to keep working during the voucher-only
weeks of a new Sovereign.
Adds checkout_test.go covering two regression paths:
- fresh-voucher path: customer with 0 credit redeems WELCOME50
against a 50-OMR plan → 200 + paid_by_credit:true, settings table
never probed (sqlmock asserts no unexpected queries).
- pre-existing-credit path: customer with 200-OMR standing balance
buys a 100-OMR plan, no promo_code in request → 200 +
paid_by_credit:true + 100-OMR leftover credit.
Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
048cb2c3de
|
fix(sme): wire tenant + billing event dispatchers to NATS (was Redpanda-only, blocking convergence) (#1626)
The tenant + billing services hardcoded a franz-go Kafka publisher
pointing at REDPANDA_BROKERS. On Sovereigns there is NO Redpanda in
cluster — only NATS JetStream at
nats-jetstream.nats-system.svc.cluster.local:4222 — so every
tenant.created / tenant.deleted / order.placed event was silently
dropped, blocking provisioning + downstream consumers and stalling
the convergence chain end to end.
Per ADR-0001 §6 the canonical event bus is NATS JetStream with
subject convention `catalyst.<domain>.<event>`. This change:
- Adds events.BrokerPublisher + events.MultiPublisher that fan out
to NATS (`catalyst.<event.Type>` derived from Event.Type) and the
legacy Redpanda topic in one call. Either transport may be nil;
the constructor refuses to build a no-op publisher (the exact
silent-failure mode we just hit).
- Adds NATSConn.PublishEvent so the generic Event envelope can flow
over the same JetStream connection used for the metering
subscriber (#798), with Event.ID as the JetStream Msg-Id for
broker-side de-dup.
- Updates tenant + billing main.go to read NATS_URL +
REDPANDA_BROKERS independently, construct the appropriate
transports, and wire MultiPublisher into the Handler. Legacy
Kafka consumers only start when REDPANDA_BROKERS is non-empty
so the pods no longer crashloop dialling localhost:9092 on
Sovereigns.
- Updates chart templates to inject NATS_URL into both tenant and
billing Deployments. ConfigMap default for NATS_URL on Sovereigns
is nats://nats-jetstream.nats-system.svc.cluster.local:4222
(fixes the existing bug where defaults pointed at the wrong
namespace `nats-jetstream` — NATS actually lives in `nats-system`
per clusters/_template/bootstrap-kit/07-nats-jetstream.yaml).
- Sovereign default of REDPANDA_BROKERS is now empty (was the wrong
NATS URL stuffed into a Kafka env, which made franz-go fail every
dial).
Subject mapping per CanonicalSubject:
tenant.created → catalyst.tenant.created
tenant.deleted → catalyst.tenant.deleted
tenant.app_install_requested → catalyst.tenant.app_install_requested
order.placed → catalyst.billing.order.placed
Test:
go build ./... in shared/, tenant/, billing/ (clean)
go test ./events/... ./handlers/... in all three (existing + new
bridge_test.go pass)
helm template with global.sovereignFQDN set renders NATS_URL in
both Deployments + REDPANDA_BROKERS="" in ConfigMap
helm template without global.sovereignFQDN renders the legacy
Redpanda broker (Catalyst-Zero contabo path remains intact)
NATS-side consumers for sme.tenant.events / sme.provision.events ship
in a follow-up PR per the ADR-0001 §6 migration plan; this PR only
unblocks the publish leg which is the immediate convergence blocker.
Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1fe706769f
|
feat(billing+notification): wire voucher-issued email (D28) (#1556)
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: hatiyildiz <hatice.yildiz@openova.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9645a9044a
|
feat(metering): NewAPI NATS publisher + sme-billing subscriber + POST /metering/record (#798) (#818)
* feat(metering): NewAPI NATS publisher + sme-billing subscriber + POST /metering/record (#798) Per #795 [Q-mine-3] (NATS not RedPanda) + [Q-mine-4] (one ledger), add the SME-2 metering integration end-to-end. NewAPI is consumed as the upstream image `ghcr.io/openova-io/openova/newapi-mirror` (a pinned mirror, not a fork) — the metering envelope is produced by a Go sidecar that observes the OpenAI-style `usage.total_tokens` field on every 2xx /v1/* response. This avoids forking the upstream binary while still producing the canonical envelope shape on `catalyst.usage.recorded`. A) NewAPI metering sidecar — core/services/metering-sidecar/ - Transparent reverse proxy in front of NewAPI on its own port; the bp-newapi Service routes the cluster-fronting port to the sidecar, which forwards to NewAPI on the pod's loopback. - Observes successful /v1/* JSON responses, parses `usage.{prompt_tokens,completion_tokens,total_tokens}`, computes amount_micro_omr = -tokens * priceMicroOMRPerToken, and publishes one envelope on `catalyst.usage.recorded` per completed request. - Failed (non-2xx), non-JSON, and admin-path requests are NOT billed. - Customer-facing latency is NEVER blocked on metering: the response body is restored before publish; on NATS unreachable the envelope is persisted to disk and retried by a background drain loop. - 14 unit tests (proxy + publisher + safeFilename guards). B) sme-billing NATS subscriber — core/services/billing/handlers/ metering_consumer.go - JetStream durable consumer `sme-billing-metering` on stream `CATALYST_USAGE` (provisioned by sme-billing on startup). - Idempotent on metadata.request_id via a UNIQUE partial index on credit_ledger.external_ref; redelivery from the broker collapses to a single ledger row. - Customer auto-create on cold start (the rbac sme.user.created envelope may land AFTER the first metered request; we don't strand usage waiting for it). - 11 unit tests covering happy-path, idempotency, malformed-payload poison-pill, missing-request-id, non-negative amount guard, resolver error → Nak, derive-micro-OMR-from-OMR, DB-error → Nak. C) HTTP handler POST /billing/metering/record — handlers/metering.go - Synchronous validate → INSERT credit_ledger → return {ledger_entry_id, balance_after_omr, balance_after_micro_omr, duplicate}. Same payload + idempotency guard as the NATS path. - Auth: superadmin OR sovereign-admin (operator-admin model; end-user LLM traffic flows through the sidecar, never this URL). - 8 unit tests covering happy-path, idempotency, role gating, malformed-JSON, positive-amount rejection, customer-not-found. D) Schema — core/services/billing/store/store.go - ALTER TABLE credit_ledger ADD COLUMN amount_micro_omr BIGINT (1 OMR = 1,000,000 micro-OMR; -0.000234 OMR = -234 micro-OMR exact integer — preserves precision at metering rates). - ADD COLUMN external_ref TEXT + UNIQUE partial index for idempotency dedup. - ADD COLUMN metadata JSONB for the raw envelope. - GetCreditBalance projects both amount_omr (legacy) and amount_micro_omr (new) into the integer-OMR view. - GetCreditBalanceMicroOMR returns canonical precision. - RecordUsage method: ON CONFLICT DO UPDATE … RETURNING (xmax<>0) distinguishes fresh insert from duplicate without a follow-up SELECT. E) Wiring - core/services/shared/events/nats.go — minimal NATS JetStream publisher + subscriber surface; legacy RedPanda producer/consumer in events.go untouched per [Q-mine-3]. - core/services/billing/main.go — NATS_URL env; subscriber wired in parallel with the existing RedPanda tenant-events consumer. - middleware/jwt.go — exported test helper WithClaims so handler tests can construct an authenticated context without minting a real signed token. - .github/workflows/services-build.yaml — metering-sidecar added to the build matrix; deploy job skips it (image consumed by the bp-newapi chart, not products/catalyst sme-services). F) bp-newapi chart (1.0.0 → 1.1.0) - meteringSidecar block in values.yaml: image, port, NATS URL, priceMicroOMRPerToken (default 156 = 0.000156 OMR/token), spool dir, header names, resources, securityContext (read-only-rootfs). - deployment.yaml renders the sidecar container + emptyDir spool volume when meteringSidecar.enabled (default true). - service.yaml routes the cluster-fronting :3000 to the sidecar when enabled, exposes a separate :3001 → NewAPI direct port for bp-catalyst-platform admin-API traffic (ADR-0003 §3.2). - networkpolicy.yaml allows the sidecar's port + nats-system egress for JetStream publish. Tests: 33 new (14 sidecar + 11 subscriber + 8 HTTP handler), all green. Helm template renders cleanly with sidecar enabled and disabled. Closes #798 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing/store): cast SUM to BIGINT so lib/pq scans into int64 (#798) Postgres returns `SUM(int) + SUM(bigint)/integer` as `numeric`, which lib/pq presents as a `[]uint8` decimal string ("50.000000000000000000000000") that does NOT scan directly into Go int64 — the integration test TestVoucherLifecycle_IssueRedeemAndCreditApplied caught this in CI on the post-redeem balance read. Wrap the SUM expressions in CAST(... AS BIGINT) so the column type is unambiguously bigint and Scan target stays uniform across pre-#798 rows (amount_omr only) and post-#798 rows (amount_micro_omr present). Affects: - GetCreditBalance - GetCreditBalanceMicroOMR - RecordUsage's running-balance read Test mocks updated to match the new SQL prefix. 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> |
||
|
|
fabedd42c1 |
feat(admin,billing): per-Sovereign voucher issuance for sovereign-admin
#115 extends the existing PromoCode (voucher) admin surface so a sovereign-admin role can issue, list, and revoke vouchers on a franchised Sovereign. No new endpoints, no new schema, no new CRD — all the changes are role-gating widenings on the existing surface. Backend (core/services/billing/handlers/handlers.go): - New `requireVoucherIssuer` helper accepts both `superadmin` and `sovereign-admin`. Used by AdminListPromos, AdminUpsertPromo, and AdminDeletePromo only. All other admin endpoints (Stripe settings, revenue, orders) keep the existing `requireAdmin` (superadmin-only). UI (core/admin/src/components/AdminShell.svelte + BillingPage.svelte): - AdminShell now accepts both roles. Sidebar nav is filtered by role: superadmin sees Revenue / Catalog / Tenants / Orders / Billing; sovereign-admin sees only Billing. Filtering is via a `superadminOnly` flag on each nav item (defence-in-depth: even if a sovereign-admin guesses a URL, the backend's requireAdmin will return 403). - BillingPage hides the Stripe Configuration section for sovereign-admin (it would 403 from GET /billing/admin/settings anyway). The Vouchers (Promo Codes) section is shown to both roles with a small label tweak ("Issued vouchers are scoped to this Sovereign" for sovereign-admin). Per docs/INVIOLABLE-PRINCIPLES.md §1 (target-state shape, no MVP) and §3 (follow documented architecture exactly) — this matches the FRANCHISE-MODEL.md design where "every franchised Sovereign runs the same admin app" with role-based gating. Closes #115. |
||
|
|
7646840ffe |
feat(consolidation): move 8 SME backend services + shared module to public repo
Per docs/PROVISIONING-PLAN.md and tickets [B] sme-backend group. Migrates the 8 Go backend services from openova-private/services/ to openova/core/services/, plus the shared module they all depend on, plus the services-build CI workflow.
What moved:
- services/auth → core/services/auth (Go HTTP service for SME marketplace authentication)
- services/billing → core/services/billing (Go HTTP service for billing + voucher backend)
- services/catalog → core/services/catalog (Go HTTP service for App catalog)
- services/domain → core/services/domain (Go HTTP service for tenant domain mapping)
- services/gateway → core/services/gateway (Go HTTP gateway with rate limiting)
- services/notification → core/services/notification (Go HTTP service with email templates)
- services/provisioning → core/services/provisioning (Go HTTP service that commits tenant Application manifests via Gitea/GitHub API)
- services/tenant → core/services/tenant (Go HTTP service for tenant lifecycle)
- services/shared → core/services/shared (shared Go module: db, events, health, middleware, respond)
- 9 go.mod files updated: module github.com/openova-io/openova-private/services/<X> → github.com/openova-io/openova/core/services/<X>
- 9 go.sum and import paths similarly updated
- replace directives updated: openova-private/services/shared → openova/core/services/shared
- sme-services-build.yaml workflow → services-build.yaml in .github/workflows/, paths/context/image-base/deploy paths all repointed at core/services + ghcr.io/openova-io/openova/services-* + products/catalyst/chart/templates/sme-services
- All 8 manifests in products/catalyst/chart/templates/sme-services/ updated: image refs ghcr.io/openova-io/openova-private/sme-{X} → ghcr.io/openova-io/openova/services-{X}
- provisioning.yaml GITHUB_REPO env var: "openova-private" → "openova"
Closes [B] sme-backend (10 tickets).
After this commit, all 14 user-facing + backend Catalyst-Zero modules build from this public repo:
- 4 UIs: console, admin, marketplace, catalyst-ui
- 2 backends: marketplace-api, catalyst-api
- 8 SME services: auth, billing, catalog, domain, gateway, notification, provisioning, tenant
- 1 shared Go module
Note: 1 line in core/services/provisioning/main.go retains a literal default of "openova-private" for the GITHUB_REPO fallback when env var is unset; the K8s manifest sets GITHUB_REPO=openova explicitly so this path is never exercised in the deployed runtime, and the in-code default will be cleaned up in a follow-up.
|