01 — Architecture Assessment¶
Current billing pipeline (actual, from repo analysis)¶
┌──────────────────────────────────────────────────────────────┐
│ Billable events accrue all month │
│ (ad serving impressions by type: ST / RM / CV) │
│ Source: Redshift (stats), queried via Pdo_model │
└──────────────────────────┬───────────────────────────────────┘
│ operator triggers month-end run
▼ (manual UI action, not a cron)
┌──────────────────────────────────────────────────────────────┐
│ Billing.php::generateInvoice() │
│ POST to billing_service: m=createInvoice │
│ billing_service aggregates line items + calls Stripe │
│ (billing_service is a separate internal API, not in repo) │
└──────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ billing_service PDF renderer │
│ Generates custom-branded PDF invoice │
│ Stores at: billing.adventivecdn.com/{acct_id}/{inv_uuid}.pdf│
│ (S3 via CloudFront — billing.adventivecdn.com) │
└──────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Admin: Billing.php::sendInvoiceReminder() │
│ Downloads PDF from CDN to /tmp/invdir/ │
│ Mailgun.php::sendTemplatedEmail() │
│ Templates: invoice_new, invoice_notify, invoice_pastdue │
│ Sender: billing-noreply@notify.adventive.com │
│ BCC: 2658568@bcc.hubspot.com (HubSpot CRM) │
└──────────────────────────┬───────────────────────────────────┘
│ operator triggers dunning manually
▼ (or via emailInvoice() batch)
┌──────────────────────────────────────────────────────────────┐
│ 6-step dunning sequence (admin-owned, operator-triggered) │
│ invoiceNew → invoiceDue7 → invoiceDue0 → │
│ invoicePastDue5 → invoicePastDue15 → invoicePastDue30 │
└──────────────────────────────────────────────────────────────┘
Current state — confirmed findings¶
-
Runtime: CodeIgniter 3.1, PHP 7.x, running on EC2 (legacy admin server). The admin is the operator-facing UI only; billing logic lives in a separate
billing_serviceinternal API atbilling.{domain}/api/billing/. -
Rollup scheduling: There is no automated cron for invoice generation. Invoices are triggered manually by an operator via
Billing.php::generateInvoice(), which POSTs tobilling_servicewithm=createInvoice. Thebilling_serviceowns the actual aggregation and Stripe API calls. Automated scheduled jobs inReporting.phpare for partner analytics reports, not billing. -
Stripe API integration: All Stripe calls originate from
billing_service, not from the admin repo. The admin repo contains zero Stripe SDK calls. The admin usesbilling_serviceas an internal API boundary: the admin POSTs form data (m=createInvoice,m=processPayment,m=getAchInfo) and billing_service executes the Stripe operations. The exact Stripe API version and SDK used by billing_service cannot be determined from the admin repo alone. -
Pricing model (confirmed from
Billing_model.php): - Tiered impression pricing via
account_plan_usagetable:usage_type(ST/RM/CV),usage_max(tier ceiling),usage_rate(per-unit rate). Tiers are graduated; the tier whereactual_impressions < usage_maxdetermines the rate. - Flat subscription fee via
account_plan_subtable:sub_name,sub_invoice_line,sub_rate. -
Managed service jobs:
ManagedServiceJob.php/ManagedService_model.php— per-job flat amounts attached to a customer for a given month. -
Custom invoice PDF renderer: PDF is generated by
billing_serviceand stored in S3 atbilling.adventivecdn.com/{acct_id}/{inv_uuid}.pdf. The admin downloads this file to/tmp/invdir/(Billing.php::_getInvoiceFile()) and attaches it as a Mailgun multipart upload. The renderer's tech stack (wkhtmltopdf, Dompdf, tcpdf, or other) is inbilling_service— not analyzable from this repo. Handlebars templates referenced in the kickoff context are in billing_service. -
Mailgun send path:
application/libraries/Mailgun.php— a custom curl-based client.sendTemplatedEmail()uses Mailgun'st:variablesHandlebars variables API. Sender:billing-noreply@notify.adventive.com. BCC: HubSpot address2658568@bcc.hubspot.comon all invoice emails. Three billing templates:invoice_new,invoice_notify,invoice_pastdue. Mailgun is a shared service — the same library handles auth emails, onboarding, partner report delivery, and general notifications. -
Reconciliation: No reconciliation logic found in the admin repo. Paid/unpaid/failed invoice status is tracked in the
billing_invoicetable (billing DB) and surfaced viaBilling_model.php::getBillingHistory(). No automated reconciliation against Stripe; operators check payment status manually via the Billing UI. -
Dispute handling: No dispute handling in the admin code. Dashboard-managed via Stripe.
-
Retry / dunning: Adventive owns a custom 6-step dunning sequence in
Billing.php::sendInvoiceReminder(). The sequence (invoiceNew → invoiceDue7 → invoiceDue0 → invoicePastDue5 → invoicePastDue15 → invoicePastDue30) is triggered by operators manually or via theemailInvoice()batch method. It downloads the PDF and attaches it to each Mailgun send. There is no evidence of Stripe Smart Retries being configured — dunning is entirely Adventive-owned today. -
Failed-payment flow:
Billing.php::processPayment()POSTs tobilling_servicewithm=processPayment. Failed payment status is surfaced to operators via the invoice list UI. No automated retry or operator alerting logic found in the admin. -
Payment link: Generated as
md5($invoice->payment_customer_id . $invoice->inv_uuid . $invoice->acct_id)— MD5-based hash link passed into invoice templates. This is not Stripe-native payment link functionality. -
Month-end Excel report:
Billing.php::getMonthSummary()uses PHPExcel (abandoned library) to generate a.xlsxfromMonthEndReportTemplate.xlsx. Reads impression data (ST/RM/CV types from Redshift), subscription info, and tiered pricing fromaccount_plan_usage. This is independent of invoice generation and is used for accounting/ops review. -
Stripe account state (confirmed via Stripe MCP):
- Account:
acct_17YHT3CSGXf35AB6(Adventive) - Products: 5 total — Account Charges, Usage (Ad Serving), Managed Services, + 2 unnamed Usage products
- Prices: 5 total — all
type=one_time; 3 haveamount=null(custom/variable per invoice); 0 recurring, 0 metered - Subscriptions: ZERO — all billing is one-off manual invoices
- Invoices: All sampled invoices have
billing_reason=manual; mix of open and paid; amounts range from $18.73 to $6,771.75
Stripe-native capability map¶
| Subsystem | Current (Adventive) | Stripe-native option | Coverage | Notes |
|---|---|---|---|---|
| Customer record | Adventive DB + Stripe Customer object | Customer |
✓ Full | Already on Stripe Customers |
| Product / price catalog | account_plan_usage + account_plan_sub tables; 5 Stripe Products exist |
Product + Price |
✗ None for recurring | 5 Products exist but all Prices are one-time with null amount — no metered/tiered Price objects |
| Line-item rollup | Manual operator trigger → billing_service → Stripe Invoice | InvoiceItem (add as-accrued) + Stripe Billing metered |
✗ None | Currently month-end lump; Stripe can accumulate as events occur |
| Invoice creation | billing_service calls Stripe Invoice API on operator trigger | Invoice + auto-advance |
◑ Partial | Stripe Invoice already used, but creation is manual, not subscription-driven |
| Invoice PDF | Custom PHP renderer in billing_service; stored in S3 CDN | Stripe Hosted Invoice Page + downloadable PDF | ✗ None | Custom renderer non-negotiable (Jeffrey 5/5); Stripe's branding not sufficient |
| Invoice delivery | Admin + Mailgun.php + billing templates | Stripe email or webhook-triggered custom | ✗ None | Mailgun stays; billing Worker handles delivery on invoice.finalized |
| Payment collection | Stripe — unchanged | Stripe — unchanged | ✓ Full | |
| Payment link | MD5 hash link in invoice template | Stripe Payment Link or Hosted Invoice Page | ✗ None | Replace with Stripe Hosted Invoice Page URL in billing Worker template |
| Failed payment retry | None — no automated retry in admin or billing_service found | Smart Retries (Stripe Billing) | ✗ None | Must configure Smart Retries as part of B.3 |
| Custom dunning | 6-step manual sequence in Billing.php; operator-triggered | Stripe dunning email schedules | ✗ None (currently) | Retire Adventive dunning; Stripe dunning takes over per ADR |
| Customer self-service | None | Customer Portal | ✗ None | B.9 optional; enables card update + invoice history self-service |
| Tax | None | Stripe Tax | ✗ None (by design) | Not collected today; design allows future enablement without re-architecture |
| Reconciliation | None | Stripe reports + API | ✗ None | Billing Worker Cron Trigger handles daily reconciliation against Acodei/QuickBooks |
| Dispute handling | Dashboard-managed | Stripe Radar + API | ◑ Partial | Stripe Dashboard used today; no Adventive code; no change needed at cutover |
| Refunds / credits | Dashboard-managed | Stripe Refunds + Credit Notes | ◑ Partial | Stripe used today; admin-api will expose operator-facing refund trigger in sibling project |
| Audit trail | billing_invoice table + Stripe events |
Stripe events + API (90-day default) | ◑ Partial | Export all historical events to R2 or BigQuery before cutover to satisfy 14-year retention |
Target architecture — chosen¶
Hybrid: Stripe owns billing logic, Adventive owns invoice presentation + email delivery. Full rationale in decisions/2026-04-23-stripe-billing-scope.md and decisions/2026-04-23-custom-invoice-pdf-retained.md.
Adventive product events (ST / RM / CV impressions)
│
▼ (as they accrue — not month-end rollup)
┌─────────────────────────────────────────────────┐
│ Billing Worker: POST /usage │
│ Idempotent metering event ingestion │
│ → Stripe Usage Record API (metered Price) │
└─────────────────────────────────────────────────┘
│ also: managed service jobs → InvoiceItem
│ flat subscription → Stripe Subscription
▼
┌─────────────────────────────────────────────────┐
│ Stripe Billing │
│ - Subscriptions (flat + metered prices) │
│ - InvoiceItems (managed services, custom) │
│ - Discounts / coupons │
│ - Smart Retries + dunning emails │
│ - Payment collection │
│ - Disputes / refunds / credit notes │
│ - Customer Portal (B.9) │
└──────────────────────┬──────────────────────────┘
│ webhook: invoice.finalized
▼
┌─────────────────────────────────────────────────┐
│ Billing Worker: POST /webhook/stripe │
│ - Verify Stripe-Signature header │
│ - Pull full invoice from Stripe API │
│ - Render Adventive-branded PDF │
│ (Cloudflare Browser Rendering API) │
│ - Upload PDF to R2 │
│ - Attach PDF to Stripe invoice (optional) │
│ - Trigger Mailgun: invoice_new / invoice_notify │
└──────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Mailgun (shared service, unchanged sender) │
│ billing-noreply@notify.adventive.com │
│ BCC: 2658568@bcc.hubspot.com │
│ Templates: invoice_new, invoice_notify │
│ invoice_pastdue (now Stripe-owned) │
└─────────────────────────────────────────────────┘
Parallel (unchanged):
Stripe events → Acodei → QuickBooks
Rejected options¶
- Full Stripe-native (Stripe sends the invoice): rejected — invoice brand fidelity is non-negotiable (Jeffrey rated 5/5). Stripe's template customization (logo, colors, custom fields) does not meet Adventive's bar.
- Status quo with cleanup: rejected — leaves the rollup, pricing calculations, dunning, and subscription handling in Adventive's codebase, defeating the leverage case.
- Partial Stripe (keep dunning in admin): rejected per ADR — Stripe owns dunning end-to-end. The existing 6-step manual dunning in Billing.php is retired; Stripe dunning configuration replaces it.
Mailgun is staying¶
Mailgun is a shared service used for billing emails, auth emails, new-user onboarding, partner report delivery, and general notifications — all with established Handlebars templates. It stays. A future migration to a Cloudflare-native email service is revisitable once Cloudflare offers a production-ready outbound email product. The billing Worker will call Mailgun directly, removing the admin as an intermediary.
Serverless viability assessment (residual Adventive-side billing)¶
| Residual subsystem | Candidate target | Viability | Notes |
|---|---|---|---|
| Webhook receiver (Stripe → Adventive) | Worker + Queue (Workers Queue for DLQ) | ✓ Excellent | Stateless event handler; well within CPU/memory limits |
Usage event ingestion (/usage) |
Worker endpoint | ✓ Excellent | Idempotent write to Stripe Usage Records API; no persistent state |
| PDF rendering | Cloudflare Browser Rendering API | ✓ Good | Leading candidate; ~$5/1000 renders; concurrency limit 2 per account — sufficient at ~200 invoices/month |
| Reconciliation batch | Cron Trigger Worker (wrangler.toml) | ✓ Excellent | Daily cron comparing Stripe totals to Acodei/QuickBooks |
| Month-end Excel export | Cron Trigger Worker (long-term) | ✓ Acceptable | PHPExcel replacement; lower priority; CSV via Worker sufficient |
| PDF storage | Cloudflare R2 | ✓ Excellent | S3-compatible; replaces billing.adventivecdn.com CDN |
Open architectural questions¶
All questions from the original stub have been resolved by analysis:
- ~~Invoice brand fidelity~~ — Resolved: 5/5 non-negotiable, custom renderer retained.
- ~~Mailgun uses~~ — Resolved: shared service, stays.
- ~~Tax posture~~ — Resolved: no tax collected today; design allows Stripe Tax enablement later.
- ~~Long-retention audit requirements~~ — Resolved: ~14 years; export pre-cutover.
- ~~Accounting integration~~ — Resolved: Acodei → QuickBooks, unchanged.
- PDF-rendering implementation — Cloudflare Browser Rendering is the leading candidate. Must confirm concurrency limit (2 simultaneous renders per account) does not create backlog during batch invoice finalization. At ~200 customers, 200 renders/month is well within capacity at non-simultaneous pacing.
- ~~Metering signal contract~~ — Refined: ST/RM/CV impression counts from Redshift are the metering signal. Billing Worker
/usageaccepts these per-account, per-type, per-period with idempotency key ={acct_id}:{period}:{type}. - ~~Pricing catalog migration~~ — Resolved: see Chapter 02 pricing-catalog migration detail and Checkpoint 1 matrix.
- Cutover cohorting — Recommended order: (1) flat-only customers (simplest migration, no metered complexity), (2) tiered impression customers, (3) managed-service-heavy and custom-arrangement customers. Driven by pricing model complexity, not account size.
- Customer Portal — B.9 optional. Enables self-service card update + invoice history. Low implementation cost; deferred until post-B.8 stability is confirmed.
- Stripe Smart Retries configuration — Must be explicitly configured before any customer cutover. No evidence Smart Retries is currently enabled on this Stripe account.
- Historical invoice export — ~14 years of invoice history in
billing_invoice(billing DB). Must be exported to R2 (or preserved in the legacy DB with read-only access) before thebilling_invoicetable is retired as the SoR.