Backend-first MVP scaffold for the PRD in Promotion_Agent_PRD_v0.9.docx.
AgentLead,PartnerAgent,Campaign,OfferCard,ProofBundle,OpportunityRequest,EventReceipt, andSettlementReceiptdomain models.Offer Compiler,Policy Engine, andCampaignlifecycle (draft -> reviewing -> active/rejected).- Opportunity Exchange flow with eligibility gates and sponsored reranking.
- Ranking formula aligned to the PRD:
eligible_i = policy_pass && relevance_i >= relevance_floor && expected_utility_i >= utility_floor && trust_i >= min_trust
priority_score_i = 0.35*relevance + 0.25*utility + 0.15*trust + 0.15*affective_fit + 0.10*bid_norm- Measurement and settlement for two MVP billing models:
CPQRbills onshortlistedCPAbills onconversion
- Seed data for two buyer agent partners and two CRM campaigns.
GET /healthGET /agents/leadsGET /partnersGET /campaignsGET /campaigns/:campaignIdGET /campaigns/:campaignId/policy-checkPOST /campaignsPOST /campaigns/:campaignId/reviewPOST /campaigns/:campaignId/activatePOST /opportunities/evaluatePOST /events/receiptsGET /settlementsGET /settlements/retry-jobsPOST /settlements/retry-queue/processPOST /settlements/:settlementId/disputeGET /dashboardGET /audit-trail
pnpm install
pnpm startServer starts on http://localhost:3000.
Stable stakeholder demo with synthetic data and automatic bootstrap activity:
pnpm start:demoDemo runs on http://localhost:3001 and:
- uses in-memory persistence only
- loads demo seed entities
- bootstraps receipts, settlements, queue state, risk, and audit activity
- exposes
GET /system/runtime-profile
Full-real-data test lane with isolated state:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm start:real-testReal test runs on http://localhost:3002 and:
- refuses to start if
DATABASE_URL,REDIS_URL, orBILLING_ADAPTER_URLis missing - seeds only approved
discovery_sources - rejects startup if the database already contains
demo_*provenance records - exposes
GET /system/runtime-profile
Initialize the real-test database with:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
pnpm db:initRun the settlement worker for real test with:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm worker:settlement:real-testImport recorded sandbox receipts into real test with:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm exec tsx scripts/import-real-test-receipts.ts ./receipts.jsonTo enable real checkout for wallet top-ups, set:
STRIPE_SECRET_KEY=sk_live_or_test
STRIPE_PRICE_PER_CREDIT_CENTS=100
STRIPE_CURRENCY=usd
STRIPE_TOP_UP_PRODUCT_NAME="Promotion Agent Credits"When Stripe is configured:
POST /wallet/top-ups/checkoutcreates a Stripe Checkout Session and returnscheckoutUrl/plans-walletredirects the user to Stripe Checkout- after payment, Stripe redirects back to
/plans-wallet?checkout=success&session_id=... - the page calls
GET /wallet/top-ups/confirmto verify the session and credit the wallet exactly once
Without Stripe configured:
democontinues to use direct sandbox top-upsreal_testreturns an error for checkout creation
Promote imported real discovery leads into real-test partners:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm exec tsx scripts/import-real-test-partners.tsOr provide an explicit partner import file:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm exec tsx scripts/import-real-test-partners.ts ./partners.jsonImport real sandbox campaigns from a controlled JSON file:
APP_MODE=real_test \
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/promotion_agent_real_test \
REDIS_URL=redis://127.0.0.1:6380/1 \
BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements \
pnpm exec tsx scripts/import-real-test-campaigns.ts ./campaigns.jsonBy default the app uses in-memory persistence and in-memory hot state. If DATABASE_URL is set, startup switches to PostgreSQL automatically. If REDIS_URL is set, idempotency keys and opportunity cache switch to Redis.
Hot-state keys are namespaced and versioned:
{HOT_STATE_NAMESPACE}:{HOT_STATE_VERSION}:cache:...
{HOT_STATE_NAMESPACE}:{HOT_STATE_VERSION}:idempotency:...
{HOT_STATE_NAMESPACE}:{HOT_STATE_VERSION}:lock:...Recommended branch protection settings for main:
- Require a pull request before merging.
- Require at least 1 approving review.
- Dismiss stale approvals when new commits are pushed.
- Require conversation resolution before merging.
- Require status check
test. - After Vercel secrets are configured, optionally also require
deploy-preview. - Block force pushes and branch deletion.
- Consider merge queue once more than one contributor is landing changes regularly.
These are recommendations only. They are not auto-enforced by this repo.
This repo includes GitHub Actions workflows for:
- preview deployments on pull requests from this repository
- production deployments on pushes to
main
Required GitHub repository secrets:
VERCEL_TOKENVERCEL_ORG_IDVERCEL_PROJECT_ID
Suggested one-time setup:
- Link the repo to a Vercel project locally:
pnpm dlx vercel@latest link- Read the generated project metadata:
cat .vercel/project.json-
Add the values to GitHub Actions secrets for this repository.
-
Add runtime environment variables in the Vercel project if you want managed PostgreSQL, Redis, or an external billing adapter.
Deployment notes:
- Vercel deploys the Fastify app entrypoint from
src/index.ts. public/assets are bundled into the Vercel function.- The settlement worker is not deployed by Vercel and should run separately.
- Embedded PostgreSQL and embedded Redis scripts are local-dev tooling only.
- Without
DATABASE_URLandREDIS_URL, Vercel previews run with in-memory state that resets across invocations.
- Start a local database:
docker compose up -d postgresOr, if Docker is unavailable on the machine, start the embedded PostgreSQL runtime:
pnpm db:embedded- Point the app to PostgreSQL:
cp .env.example .env
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/promotion_agentFor the embedded runtime, use:
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/postgres- Initialize schema and seed data:
pnpm db:init- Start the app:
pnpm startThe startup log will say using postgres persistence when PostgreSQL is active.
Redis is used for:
- receipt idempotency keys and lock coordination
- short TTL cache for repeated opportunity evaluation
Start a local Redis instance:
pnpm redis:embeddedThen point the app to it:
export REDIS_URL=redis://127.0.0.1:6380
export HOT_STATE_NAMESPACE=promotion-agent
export HOT_STATE_VERSION=v1When Redis is active, the startup log will say using redis hot-state.
Current TTL policy:
- opportunity cache:
12s - receipt idempotency result:
15m - receipt lock lease:
8s - settlement retry lease:
10s
Settlement statuses:
pendingprocessingretry_scheduledsettleddisputedfailed
Every billable settlement creates a retry job. Use the queue processor to advance jobs:
curl -X POST http://127.0.0.1:3000/settlements/retry-queue/process \
-H 'content-type: application/json' \
-d '{"limit": 20}'You can inspect retry jobs with:
curl 'http://127.0.0.1:3000/settlements/retry-jobs?limit=20'You can mark a settlement disputed with:
curl -X POST http://127.0.0.1:3000/settlements/<settlementId>/disputeInstead of manually calling the queue processor, run the background worker:
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/postgres
export REDIS_URL=redis://127.0.0.1:6380
export BILLING_PROVIDER_PROFILE=generic_v1
pnpm worker:settlementOptional worker tuning:
export SETTLEMENT_WORKER_INTERVAL_MS=5000
export SETTLEMENT_WORKER_BATCH_SIZE=20
export SETTLEMENT_WORKER_LEADER_LEASE_MS=15000Leader election:
- The worker uses a Redis lease on
leader:settlement-worker - Only the elected leader processes retry jobs
- Standby workers keep metrics and health endpoints but skip queue work
Promotion outreach can now send directly over SMTP, including 163 Mail.
For 163 Mail, configure:
export OUTREACH_SENDER_MODE=smtp
export OUTREACH_SMTP_PROVIDER=163
export OUTREACH_SMTP_HOST=smtp.163.com
export OUTREACH_SMTP_PORT=465
export OUTREACH_SMTP_SECURE=true
export OUTREACH_SMTP_USER=songyili2026@163.com
export OUTREACH_SMTP_PASS=<163_client_authorization_code>
export OUTREACH_SMTP_FROM="Lumio Partnerships <songyili2026@163.com>"
export OUTREACH_SMTP_REPLY_TO=songyili2026@163.com
export OUTREACH_TRACKING_BASE_URL=https://promo.lumio.aiNotes:
OUTREACH_SMTP_PASSis the 163 client authorization code, not the mailbox login password.OUTREACH_TRACKING_BASE_URLis optional, but required if you want real email open tracking via/outreach/open/:targetId/pixel.gif.- SMTP mode currently sends real outreach for
emailandpartner_introchannels.
The default gateway is simulated. To switch to a real external HTTP adapter:
export BILLING_ADAPTER_URL=http://127.0.0.1:8787/settlements
export BILLING_PROVIDER_PROFILE=generic_v1
export BILLING_ADAPTER_API_KEY=
export BILLING_ADAPTER_TIMEOUT_MS=5000
export BILLING_ADAPTER_HMAC_SECRET=
export BILLING_ADAPTER_SIGNATURE_HEADER=x-billing-signature
export BILLING_ADAPTER_TIMESTAMP_HEADER=x-billing-timestampThe current provider contract is billing.settlement.v1, sent as JSON:
{
"contract_version": "billing.settlement.v1",
"settlement": {
"settlement_id": "set_xxx",
"trace_id": "int_xxx",
"billing_model": "CPQR",
"event_type": "shortlisted",
"amount": {"value": 160, "currency": "USD"},
"attribution_window": "session",
"status": "processing",
"generated_at": "2026-03-11T18:59:19.010Z"
},
"context": {
"campaign_id": "cmp_xxx",
"offer_id": "offer_xxx",
"partner_id": "partner_xxx",
"intent_id": "int_xxx"
},
"delivery": {
"retry_job_id": "retry_xxx",
"attempts": 1,
"sent_at": "2026-03-11T18:59:19.804Z"
}
}Expected response semantics:
{
"status": "accepted | settled | retry | failed",
"provider_settlement_id": "provider_xxx",
"provider_reference": "optional_ref",
"code": "PROVIDER_CODE",
"message": "human readable message"
}When BILLING_ADAPTER_HMAC_SECRET is configured, requests are signed as:
x-billing-timestampx-billing-signature = HMAC_SHA256(timestamp + "." + rawBody)
Supported provider profiles:
generic_v1Uses the nestedbilling.settlement.v1contract and generic status mapping.ledger_api_v2Uses the flatledger.settlement.v2contract and a code table includingPAID,RATE_LIMITED,INVALID_PAYLOAD, andDUPLICATE_SETTLED.
For local verification, start the mock adapter:
pnpm billing:mockWhen configured, both the web app and the settlement worker log http billing adapter.
The settlement worker exposes Prometheus metrics on:
http://127.0.0.1:${SETTLEMENT_WORKER_METRICS_PORT:-9464}/metricsCurrent metrics include:
promotion_agent_worker_runs_totalpromotion_agent_worker_jobs_processed_totalpromotion_agent_worker_jobs_settled_totalpromotion_agent_worker_jobs_retried_totalpromotion_agent_worker_jobs_failed_totalpromotion_agent_worker_jobs_skipped_totalpromotion_agent_worker_alerts_sent_totalpromotion_agent_worker_retry_jobs_openpromotion_agent_worker_dlq_open_totalpromotion_agent_worker_last_run_duration_secondspromotion_agent_worker_last_run_timestamp_seconds
Alert sinks:
export ALERT_WEBHOOK_URL=http://127.0.0.1:8790/webhook
export ALERT_WEBHOOK_API_KEY=
export SLACK_WEBHOOK_URL=http://127.0.0.1:8790/slack
export ALERT_SUPPRESSION_SECONDS=300Alert suppression:
- Alerts are fingerprinted from title + summary details
- Repeated failures inside the suppression window are dropped instead of re-sent
- Suppressed alerts increment
promotion_agent_worker_alerts_suppressed_total
For local verification, start the alert mock:
pnpm alerts:mockDead letters are persisted and exposed through:
GET /settlements/dlqPOST /settlements/dlq/:dlqEntryId/replayPOST /settlements/dlq/:dlqEntryId/resolve
The manual operations page is:
/dlq.htmlDLQ actions supported from UI and API:
- replay failed settlements back into the retry queue
- mark entries
resolved - mark entries
ignored
The main dashboard shows the latest audit events. For paginated trace inspection, open:
/audit.html?traceId=<traceId>&page=1&pageSize=20pnpm test- The service now supports two persistence modes:
- default: in-memory for fast local demos
- with
DATABASE_URL: PostgreSQL with schema bootstrap and seed data
- The service now supports two hot-state modes:
- default: in-memory idempotency and cache
- with
REDIS_URL: Redis-backed idempotency keys, distributed locks, and opportunity cache
GET /audit-trailnow supports pagination and trace filters.- Settlement processing now runs through a retry queue and explicit state machine instead of staying forever in
pending. POST /campaignsnow creates a draft campaign, compiles itsOfferCard, runs policy precheck, and requires explicit activation before it can join ranking.- The next production step is to make PostgreSQL and Redis the default environments, then split the current modules into the PRD's P0 services.