Skip to content

ColinLi98/promotion-agent

Repository files navigation

Promotion Agent

CI

Backend-first MVP scaffold for the PRD in Promotion_Agent_PRD_v0.9.docx.

What is implemented

  • AgentLead, PartnerAgent, Campaign, OfferCard, ProofBundle, OpportunityRequest, EventReceipt, and SettlementReceipt domain models.
  • Offer Compiler, Policy Engine, and Campaign lifecycle (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:
    • CPQR bills on shortlisted
    • CPA bills on conversion
  • Seed data for two buyer agent partners and two CRM campaigns.

Endpoints

  • GET /health
  • GET /agents/leads
  • GET /partners
  • GET /campaigns
  • GET /campaigns/:campaignId
  • GET /campaigns/:campaignId/policy-check
  • POST /campaigns
  • POST /campaigns/:campaignId/review
  • POST /campaigns/:campaignId/activate
  • POST /opportunities/evaluate
  • POST /events/receipts
  • GET /settlements
  • GET /settlements/retry-jobs
  • POST /settlements/retry-queue/process
  • POST /settlements/:settlementId/dispute
  • GET /dashboard
  • GET /audit-trail

Run

pnpm install
pnpm start

Server starts on http://localhost:3000.

Runtime Modes

Demo

Stable stakeholder demo with synthetic data and automatic bootstrap activity:

pnpm start:demo

Demo 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

Real Test

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-test

Real test runs on http://localhost:3002 and:

  • refuses to start if DATABASE_URL, REDIS_URL, or BILLING_ADAPTER_URL is 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:init

Run 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-test

Import 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.json

Stripe Top-Up Checkout

To 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/checkout creates a Stripe Checkout Session and returns checkoutUrl
  • /plans-wallet redirects the user to Stripe Checkout
  • after payment, Stripe redirects back to /plans-wallet?checkout=success&session_id=...
  • the page calls GET /wallet/top-ups/confirm to verify the session and credit the wallet exactly once

Without Stripe configured:

  • demo continues to use direct sandbox top-ups
  • real_test returns 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.ts

Or 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.json

Import 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.json

By 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:...

GitHub Guardrails

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.

Vercel

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_TOKEN
  • VERCEL_ORG_ID
  • VERCEL_PROJECT_ID

Suggested one-time setup:

  1. Link the repo to a Vercel project locally:
pnpm dlx vercel@latest link
  1. Read the generated project metadata:
cat .vercel/project.json
  1. Add the values to GitHub Actions secrets for this repository.

  2. 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_URL and REDIS_URL, Vercel previews run with in-memory state that resets across invocations.

PostgreSQL

  1. Start a local database:
docker compose up -d postgres

Or, if Docker is unavailable on the machine, start the embedded PostgreSQL runtime:

pnpm db:embedded
  1. Point the app to PostgreSQL:
cp .env.example .env
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/promotion_agent

For the embedded runtime, use:

export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54329/postgres
  1. Initialize schema and seed data:
pnpm db:init
  1. Start the app:
pnpm start

The startup log will say using postgres persistence when PostgreSQL is active.

Redis Hot State

Redis is used for:

  • receipt idempotency keys and lock coordination
  • short TTL cache for repeated opportunity evaluation

Start a local Redis instance:

pnpm redis:embedded

Then point the app to it:

export REDIS_URL=redis://127.0.0.1:6380
export HOT_STATE_NAMESPACE=promotion-agent
export HOT_STATE_VERSION=v1

When 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 State Machine

Settlement statuses:

  • pending
  • processing
  • retry_scheduled
  • settled
  • disputed
  • failed

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>/dispute

Settlement Worker

Instead 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:settlement

Optional worker tuning:

export SETTLEMENT_WORKER_INTERVAL_MS=5000
export SETTLEMENT_WORKER_BATCH_SIZE=20
export SETTLEMENT_WORKER_LEADER_LEASE_MS=15000

Leader 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

Outreach Sender

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.ai

Notes:

  • OUTREACH_SMTP_PASS is the 163 client authorization code, not the mailbox login password.
  • OUTREACH_TRACKING_BASE_URL is optional, but required if you want real email open tracking via /outreach/open/:targetId/pixel.gif.
  • SMTP mode currently sends real outreach for email and partner_intro channels.

Billing Adapter

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-timestamp

The 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-timestamp
  • x-billing-signature = HMAC_SHA256(timestamp + "." + rawBody)

Supported provider profiles:

  • generic_v1 Uses the nested billing.settlement.v1 contract and generic status mapping.
  • ledger_api_v2 Uses the flat ledger.settlement.v2 contract and a code table including PAID, RATE_LIMITED, INVALID_PAYLOAD, and DUPLICATE_SETTLED.

For local verification, start the mock adapter:

pnpm billing:mock

When configured, both the web app and the settlement worker log http billing adapter.

Worker Metrics And Alerts

The settlement worker exposes Prometheus metrics on:

http://127.0.0.1:${SETTLEMENT_WORKER_METRICS_PORT:-9464}/metrics

Current metrics include:

  • promotion_agent_worker_runs_total
  • promotion_agent_worker_jobs_processed_total
  • promotion_agent_worker_jobs_settled_total
  • promotion_agent_worker_jobs_retried_total
  • promotion_agent_worker_jobs_failed_total
  • promotion_agent_worker_jobs_skipped_total
  • promotion_agent_worker_alerts_sent_total
  • promotion_agent_worker_retry_jobs_open
  • promotion_agent_worker_dlq_open_total
  • promotion_agent_worker_last_run_duration_seconds
  • promotion_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=300

Alert 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:mock

DLQ Console

Dead letters are persisted and exposed through:

  • GET /settlements/dlq
  • POST /settlements/dlq/:dlqEntryId/replay
  • POST /settlements/dlq/:dlqEntryId/resolve

The manual operations page is:

/dlq.html

DLQ actions supported from UI and API:

  • replay failed settlements back into the retry queue
  • mark entries resolved
  • mark entries ignored

Audit Drill-Down

The main dashboard shows the latest audit events. For paginated trace inspection, open:

/audit.html?traceId=<traceId>&page=1&pageSize=20

Test

pnpm test

Notes

  • 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-trail now supports pagination and trace filters.
  • Settlement processing now runs through a retry queue and explicit state machine instead of staying forever in pending.
  • POST /campaigns now creates a draft campaign, compiles its OfferCard, 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.

About

Backend-first MVP scaffold for Promotion Agent

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors