# Billing & Payments Architecture **Last updated:** 2026-06-18 > ⚠️ **Reality check (2026-06):** Large parts of this doc describe a *planned* > "ERPNext owns all billing via Adyen" architecture that is **NOT live**. What is > actually wired today: > - **Live payment rail = Stripe Checkout** (card + ACH), plus **PayPal** (direct > Orders v2) and **crypto** (SHKeeper) — all in `api/src/routes/checkout.ts`. > **Klarna** runs via Stripe. > - **Adyen is NOT integrated** (account approval never completed). The > `Adyen-*` gateway names below are aspirational labels, not active gateways. > - **Recurring billing = Stripe Subscriptions** (see "Stripe-native > Subscriptions"), the only recurring billing actually shipping, used by > `oig-sam-screening` ($79/mo). ERPNext `createSubscription()` is unused. > ERPNext is still the system of record for invoices/accounting, but it is **not** > the payment gateway. Treat Adyen/ERPNext-gateway sections as future plan. ## Principle: ERPNext Owns All Billing All payment processing, invoicing, and financial record-keeping flows through ERPNext. Our Express API and website are the storefront — ERPNext is the back office. Payment gateways are Frappe apps installed inside ERPNext. ## Payment Methods | Method | Gateway | Provider App | Integration | |--------|---------|--------------|-------------| | Credit/Debit (Visa, MC, Amex) + Apple Pay + Google Pay | Adyen | `frappe_adyen` | ERPNext Payment Request → Adyen Sessions API v71 | | ACH Direct Debit | Adyen | `frappe_adyen` | ERPNext Payment Request → Adyen ACH | | Klarna (Pay in 4) | Adyen | `frappe_adyen` | ERPNext Payment Request → Adyen Klarna | | Cash App Pay | Adyen | `frappe_adyen` | ERPNext Payment Request → Adyen CashApp | | Amazon Pay | Adyen | `frappe_adyen` | ERPNext Payment Request → Adyen AmazonPay | | Cryptocurrency (BTC/ETH/USDC/USDT/MATIC/TRX/BNB/LTC/DOGE) | SHKeeper | `frappe_crypto` | ERPNext Payment Request → SHKeeper API (k3s) | | Stripe Identity | Stripe | Direct (API only) | Identity verification for CRTC orders — NOT used for payments | **Note:** Adyen account approval is pending. SHKeeper is deployed and running in k3s. ## Payment Surcharges We pass processor costs through as surcharges on select payment methods: | Method | Customer Surcharge | Our Gateway Cost | Notes | |--------|-------------------|-----------------|-------| | ACH Direct Debit | 0% | $0.40 flat | Recommended — lowest cost | | Credit/Debit Card | 3% | ~2.2% (IC++) | Visa/MC/Amex + Apple Pay + Google Pay | | Klarna | 5% | 4.29% + $0.30 | Adyen Klarna rate | | Cash App Pay | 3% | 2.90% + $0.30 | | | Amazon Pay | 3% | ~2.9-3.4% | Negotiated rate | | Cryptocurrency | 0% | $0 | Self-hosted SHKeeper — zero fees | **Surcharge injection** is handled by `performancewest_erpnext` via a `Payment Request.before_insert` hook that reads the surcharge rate from the payment gateway and adds it to the invoice total. **Legal notes:** - Surcharges are prohibited in **CT, MA, and PR** — residents of these jurisdictions are not charged surcharges. - Surcharges apply to **service fees only**, not state filing fees. ### SHKeeper (Crypto Payments) Self-hosted in k3s (Kubernetes) at `pay.performancewest.net`. Zero processing fees — fully non-custodial. Supports BTC, ETH, USDC, USDT, MATIC, TRX, BNB, LTC, DOGE, and any ERC-20/TRC-20/BEP-20 token. Installed via Helm chart `vsys-host/shkeeper`. k3s runs with `--docker --disable=traefik` to avoid port conflicts with host nginx. ``` Customer chooses crypto payment → ERPNext creates Payment Request (gateway: Crypto) → frappe_crypto calls SHKeeper POST /api/v1//payment_request → Customer sees wallet address + QR code on crypto_checkout page → SHKeeper webhook fires (must return HTTP 202, not 200) → frappe_crypto.api.crypto_webhook verifies X-Shkeeper-Api-Key header → ERPNext marks Payment Request as Paid → ERPNext workflow webhook → Express API → Workers ``` ### Adyen (Card/ACH/Klarna/CashApp/AmazonPay) Pending Adyen account approval. When live, 5 gateway instances will be configured: | Instance | Payment Methods | Adyen Type | |----------|----------------|------------| | Card | Visa, MC, Amex, Apple Pay, Google Pay | scheme, applepay, googlepay | | ACH | US bank account direct debit | ach | | Klarna | Pay in 4 installments | klarna | | CashApp | Cash App Pay | cashapp | | AmazonPay | Amazon Pay | amazonpay | `frappe_adyen` uses Adyen Sessions API v71 with HMAC-SHA256 webhook verification using Adyen's field concatenation algorithm. 74 unit tests passing. ## Payment Flow ``` ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ Website │───►│ Express │───►│ ERPNext │───►│ Adyen │ │ (Astro) │ │ API │ │ │ │ (Card/ACH/ │ │ │ │ │ │ Sales Invoice│ │ Klarna/ │ │ Customer │ │ Validate │ │ Payment Req │ │ CashApp/ │ │ fills │ │ + create │ │ │ │ AmazonPay) │ │ order │ │ in ERPNext│ │ │ ├──────────────┤ └──────────┘ └──────────┘ └──────┬───────┘ │ SHKeeper │ │ │ (Crypto) │ ┌────────▼────────┐ └──────────────┘ │ Customer is │ │ redirected to │ │ payment page │ │ │ │ Adyen checkout │ │ or crypto QR │ └────────┬────────┘ │ ┌────────▼────────┐ │ Gateway webhook │ │ → ERPNext marks │ │ Invoice Paid │ │ │ │ ERPNext webhook │ │ → Express API │ │ → Workers │ └─────────────────┘ ``` ## Invoice Types ### Formation Orders ``` Sales Invoice: Customer: Sarah Chen Items: - LLC Formation (Basic): $179.00 - State Filing Fee (Wyoming): $100.00 - EIN Obtainment: $49.00 - Operating Agreement: $99.00 Discount: -$37.25 (LAUNCH25 — 25% off service fee) Total: $360.75 Payment Gateway: Adyen-Card Status: Paid ``` ### Compliance Services ``` Sales Invoice: Customer: Marcus Johnson Items: - FLSA Wage & Hour Audit (up to 50 employees): $1,499.00 Total: $1,499.00 Payment Gateway: Adyen-ACH Status: Unpaid → Payment Request Sent ``` ### Recurring Services (Subscriptions) > **Status:** the only recurring billing actually wired today is **Stripe-native > Subscriptions** (see next section), used by `oig-sam-screening` ($79/mo). The > ERPNext-Subscription / Adyen model below is **planned, not yet live** — Adyen > is not integrated and ERPNext `createSubscription()` is currently unused. The > services listed here are aspirational pricing, not active subscriptions. ERPNext Subscription DocType is intended to handle (NOT YET LIVE): - Registered Agent: $99/year per state (Wyoming: $49/year) - Annual Report Filing: $99/year per state - Canada CRTC Annual Maintenance: $349/year - US Formation Maintenance Bundle: $179/year (annual report + RA renewal) - CA Formation Maintenance Bundle: $179/year (annual return + AMB/RA renewal) When built, subscriptions would auto-generate invoices (payment via a saved payment method or manual payment link). ### Stripe-native Subscriptions (healthcare monitoring) Some compliance services are sold as **Stripe Subscriptions** (the billing engine is Stripe, not ERPNext). A service opts in via the catalog (`api/src/service-catalog.ts`): ```ts "oig-sam-screening": { name: "OIG/SAM Exclusion Screening (Monthly Monitoring)", price_cents: 7900, // $79/month billing_interval: "month", // -> checkout builds mode:"subscription" allowed_methods: ["card", "ach"], // recurring needs off-session-capable rails ... } ``` Flow: 1. `checkout.ts` sees `billing_interval` -> creates a `mode:"subscription"` Checkout Session with recurring `price_data`. The gateway surcharge is **absorbed** (a subscription can't carry a one-time surcharge line) so the customer is billed a clean `$79/month`. 2. `allowed_methods` filters the picker in `PaymentStep.astro` (PayPal/Klarna/ crypto are one-time only and disappear) and is **re-validated server-side** in `checkout.ts` (`METHOD_NOT_ALLOWED`). 3. `webhooks.ts` handles the subscription lifecycle: - `checkout.session.completed` (mode=subscription) -> records `compliance_orders.stripe_subscription_id`, then first fulfillment. - `invoice.paid` with `billing_reason=subscription_cycle` -> re-dispatches the service handler (`recurring_cycle:true`) to re-run the screening + deliver a fresh dated certificate. (The first invoice is skipped here — already handled by `checkout.session.completed`.) - `invoice.payment_failed` -> admin alert + first-failure customer nudge. - `customer.subscription.deleted` -> order marked `cancelled`, fulfillment stops. **Stripe webhook events (ENABLED on the live prod endpoint** `we_1THBjyB46qMvF2jnYyN8IfkK` = `https://api.performancewest.net/api/v1/webhooks/stripe`**):** The 6 currently enabled events are `checkout.session.completed`, `payment_intent.succeeded`, `payment_intent.payment_failed`, plus the three subscription-lifecycle events added 2026-06-18: - `invoice.paid` - `invoice.payment_failed` - `customer.subscription.deleted` Without these three the monthly cycles would charge but never fulfill/alert. (Note: the code also *handles* `charge.dispute.created` and `balance.available` but those are NOT yet enabled on the endpoint — enable them if/when needed.) To add events without clobbering existing ones, read `enabled_events`, union, and PUT the union back via `POST /v1/webhook_endpoints/{id}` with repeated `enabled_events[]=` params (re-running is idempotent). > **API-version caveat (important):** this endpoint has **no pinned > `api_version`**, so it follows the Stripe *account default* (currently > `2024-12-18.acacia`), NOT the `2026-03-25.dahlia` the SDK is pinned to. In > acacia the subscription link is the **top-level `invoice.subscription`**; in > dahlia it moved to `invoice.parent.subscription_details.subscription`. > `invoiceSubscriptionId()` in `webhooks.ts` reads **both** shapes so renewals > map back to the order regardless. If you ever pin the endpoint to dahlia, the > handler still works; do NOT remove the legacy fallback while the endpoint is > unpinned. The `provider-compliance-bundle` ($899/yr) includes **only the first** OIG/SAM screening; customers are converted to the $79/mo monitoring subscription after that first cycle (the standalone $948/yr of monitoring is no longer given away inside the bundle). **Validation status (2026-06-18):** - ✅ *Checkout half — proven against LIVE Stripe.* A dry-run created a real `mode:subscription` Checkout Session with the exact production params (recurring `price_data`, `unit_amount=7900`, `recurring.interval=month`, `card`+`us_bank_account`, metadata) — Stripe returned `amount_total=7900`, `type=recurring`, then the session was immediately **expired** (creating a session never charges anyone; only a completed hosted page does). Net effect on prod: zero. - ✅ *Webhook subscription-id extraction* — `invoiceSubscriptionId()` unit tests (31 in `api/tests/recurring-subscription.test.ts`) cover acacia (top-level) AND dahlia (nested) invoice shapes, renewal-cycle gating, surcharge suppression, recurring line-item build. - ✅ *Worker renewal fulfillment* — `scripts/workers/services/test_npi_recurring.py` (13 assertions) runs the real handler and asserts the `[Monthly cycle]` / re-screen behaviour; passes locally + in the deployed workers container. - ⏳ *Full end-to-end with a Stripe test clock* — NOT yet run. Requires `STRIPE_TEST_SECRET_KEY` / `STRIPE_TEST_WEBHOOK_SECRET` in the server `.env` (currently unset; prod is `NODE_ENV=production`). Once those exist: place a test-card subscription, advance a billing cycle via a test clock, and confirm the live `invoice.paid` (subscription_cycle) re-dispatches the screening worker and a fresh certificate is issued. This is the last gap before a real recurring charge should be marketed. ### Formation Maintenance Bundles | Bundle | Price | Includes | Savings | |--------|-------|---------|---------| | US Formation Maintenance | $179/yr | Annual Report filing ($99) + RA renewal ($99) | $19/yr vs separate | | CA Formation Maintenance | $179/yr | Annual Return filing ($99) + compliance monitoring ($99). AMB mailbox renewal billed separately at cost. | N/A | | CRTC Maintenance (existing) | $349/yr | All of CA maintenance + CRTC + CCTS + domain/email + DID | N/A | Formation maintenance bundles are offered to Complete tier customers. Basic tier customers can purchase individual services (Annual Report $99/yr, RA $99/yr) separately. ### Canadian Formation Pricing (Standalone — Not CRTC) | Item | Price | Notes | |------|-------|-------| | Canadian Formation | C$449 | Includes: incorporation, org minutes, corporate binder, compliance calendar. AMB mailbox billed separately. | | Government fees | Passed through | BC ~C$350, ON ~C$360 (BoC rate + 10% buffer) | | Add-on: CRA BN | $49 | Business Number registration with CRA | | Add-on: Named company | +gov fee | Name reservation (province-specific) | | Free DID | Included | With formation + RA renewal (stub — not yet active) | ## Service Bundles Customers receive **20% off** when purchasing all services in a category (e.g., all formation add-ons, all compliance services for a given tier). - Discount applies to **service fees only** — state filing fees and registered agent fees included in bundles are not discounted. - RA fees are NOT discountable in bundles, but YES with discount codes. - Bundle discount is calculated in ERPNext via Pricing Rules and applied automatically when all qualifying items are in the Sales Order. ## Canada CRTC Package Pricing | Item | Price | |------|-------| | CRTC Telecom Registration (one-time) | $3,899 USD | | Annual Maintenance & Compliance | $349/yr | | Consulting (regulatory, technical) | $75/hr | | Accounting Support | 3 hrs free included, then $75/hr | The CRTC package is a single Sales Order in ERPNext with all line items. Annual maintenance is handled via ERPNext Subscription (auto-renew via Adyen saved payment method). Payment flexibility: ~$975/mo x 4 via Klarna Pay in 4 (5% surcharge applies). ## Sales Agent Commissions Sales agents earn commissions on referred sales: | Detail | Value | |--------|-------| | Canada CRTC sale | $300 per sale | | Formation sale | $50 per sale | | Bundle sale | $100 per sale | | Payment timing | 14 days after order delivery | | Payment method | Relay ACH transfer | | Tracking | ERPNext Commission Ledger DocType + PostgreSQL backup | Commission workflow: 1. Customer purchases using agent's REF-XXXXX referral code 2. Client gets 5% off service fee 3. Order is fulfilled and delivered 4. 14-day holdback period begins 5. After holdback, commission becomes eligible 6. Admin approves and pays via Relay ACH 7. Commission Ledger record updated with payment details ## Refunds Refunds flow through ERPNext's Credit Note system: 1. Admin creates a Credit Note against the original Sales Invoice 2. ERPNext processes the refund via the original gateway (Adyen automatic refund, or manual ACH via Relay) 3. Credit Note tracks: amount, reason, linked invoice, approval 4. Our `refunds` table in PostgreSQL is a backup/audit — ERPNext is the source of truth For state filing fees specifically: - State fee refunds require contacting the state directly (most states don't refund filing fees) - Our service fee is always refundable if the failure was our fault - ERPNext tracks whether state fee is recoverable separately ## Express API Changes Our API routes no longer handle payment directly: | Before | After | |--------|-------| | API collects payment info | API creates Sales Invoice + Payment Request in ERPNext | | API charges gateway | ERPNext gateway (Adyen/SHKeeper) handles payment | | API stores payment records | ERPNext manages invoices | | API handles refunds | ERPNext issues Credit Notes | The checkout flow: 1. Customer fills order on Astro site → submits to Express API 2. Express API creates ERPNext Sales Invoice + Payment Request 3. Customer is redirected to ERPNext payment page (Adyen checkout or crypto QR) 4. Payment gateway webhook → ERPNext marks as Paid 5. ERPNext workflow webhook → Express API → Workers start fulfillment Old `providers/` directory (stripe.ts, btcpay.ts, adyen.ts) and `webhooks-stripe.ts` have been deleted. ## ERPNext Setup Status Completed: - [x] Install Payments app (`bench get-app payments`) - [x] Install `frappe_crypto` (SHKeeper gateway) — v1.0.0 - [x] Install `frappe_adyen` (Adyen gateway) — v1.0.0 - [x] Install `performancewest_erpnext` (surcharge hooks, identity gate) — v1.0.0 - [x] Create 16 Item records for all services (formation, compliance, add-ons, CRTC) - [ ] Update Subscription Plans to new pricing (RA $99/yr ($49 WY), Annual Report $99/yr, CRTC Maintenance $349/yr) - [x] Import 7 custom DocTypes - [x] Import 3 workflows (Formation, CRTC, Renewal) - [x] Configure custom fields on Sales Order, Sales Invoice, Payment Request Remaining: - [ ] Configure Crypto Payment Settings in ERPNext UI (point to `https://pay.performancewest.net` with SHKeeper API key) - [ ] Create Payment Gateway Account for `Crypto-Crypto` - [ ] Configure 5 Adyen Settings instances when account is approved - [ ] Create Payment Gateway Accounts for each Adyen instance - [ ] Configure email templates for invoice/payment notifications - [ ] Test end-to-end payment flows ## Subscription Management ERPNext Subscription handles recurring billing: | Service | Frequency | Auto-Renew | |---------|-----------|------------| | Registered Agent | Annual | Yes (Adyen saved payment method) | | Annual Report Filing | Annual | Yes (Adyen saved payment method) | | CRTC Annual Maintenance | Annual | Yes (Adyen saved payment method) | Subscription lifecycle: 1. Customer purchases RA service during formation 2. ERPNext creates Subscription (start date = formation date) 3. 11 months later: ERPNext sends renewal reminder email 4. On anniversary: ERPNext auto-generates invoice + Payment Request 5. Adyen charges saved payment method (or customer pays via link) 6. If payment fails: ERPNext sends dunning emails 7. After grace period: service paused, customer notified ## Compliance Calendar Renewal Billing The `renewal_worker.py` (daily cron at 7 AM) manages billing for compliance calendar entries. This is separate from ERPNext Subscriptions — it handles the 17 per-carrier compliance obligations that have varying due dates and amounts. ### Lifecycle ``` Upcoming → Due Soon (30 days out) → Invoice Sent → Paid → Completed → re-calendar next year ``` 1. **Upcoming:** Entry exists with future due date 2. **Due Soon:** 30 days before deadline — renewal worker sends reminder email 3. **Invoice Sent:** Worker creates ERPNext Sales Invoice for billable items 4. **Paid:** ERPNext webhook fires on invoice payment → `handle_renewal_payment` job 5. **Completed:** Entry marked done, admin ToDo created for the actual filing/action 6. **Re-calendar:** New entry auto-created for next period (annual/quarterly/monthly) ### Billable vs Non-Billable Entries | Type | Billable | Amount | ERPNext Item | |------|----------|--------|--------------| | CRTC Annual Maintenance | Yes | $349 USD | `CRTC-MAINT-ANNUAL` | | Mailbox Renewal | Yes | ~$199 USD | `MAILBOX-RENEWAL` | | BC Annual Report | Yes | ~$50 CAD | `BC-ANNUAL-REPORT` | | Domain + Hosting Renewal | Yes | ~$25 USD | `DOMAIN-RENEWAL-CA` | | DID Renewal | Yes | ~$10 USD | (included in CRTC maintenance) | | CCTS Renewal | No | $0 | — | | T2 Tax Return | No | $0 (client's accountant) | — | | GST/HST Return | No | $0 (client's accountant) | — | | CRTC Registration Update | No | $0 | — | | ATS Surveys | No | $0 (we prepare, client files) | — | ### ERPNext Items Needed for Renewal Invoicing These ERPNext Items must be created for the renewal worker to generate invoices: | Item Code | Item Name | Rate (USD) | |-----------|-----------|-----------| | `CRTC-MAINT-ANNUAL` | CRTC Annual Maintenance & Compliance | $349 | | `MAILBOX-RENEWAL` | Vancouver Mailbox Renewal (Annual) | $199 | | `BC-ANNUAL-REPORT` | BC Annual Report Filing | $50 | | `DOMAIN-RENEWAL-CA` | .ca Domain + Hosting + Email Renewal | $25 | | `COMPLIANCE-OTHER` | Compliance Service (Miscellaneous) | Variable | ### Compliance Calendar DocType Fields The Compliance Calendar ERPNext DocType includes these billing-related fields: | Field | Type | Purpose | |-------|------|---------| | `amount_usd` | Currency | Billable amount in USD | | `amount_cad` | Currency | Billable amount in CAD (for Canadian gov fees) | | `invoice` | Link (Sales Invoice) | ERPNext invoice reference | | `recurring` | Check | Whether entry recurs annually | | `recurrence_period` | Select | Annual / Quarterly / Monthly | | `renewal_of` | Link (Compliance Calendar) | Previous entry this renews | | `compliance_type` | Select | Regulatory / Tax / Survey | | `entity_name` | Data | Carrier/company name | | `order_reference` | Link (Sales Order) | Original CRTC order | | `reminder_date` | Date | When to send reminder (30 days before due) | ## Environment Variables ``` # ERPNext API (set in Express API .env) ERPNEXT_URL=https://crm.performancewest.net ERPNEXT_API_KEY= ERPNEXT_API_SECRET= # SHKeeper (set in server .env) SHKEEPER_API_KEY= # NEEDS TO BE SET SHKEEPER_URL=https://pay.performancewest.net # Stripe Identity only (NOT for payments) STRIPE_IDENTITY_WEBHOOK_SECRET= # NEEDS TO BE SET # SMTP SMTP_PASS= # NEEDS TO BE SET # Customer Portal CUSTOMER_JWT_SECRET= # NEEDS TO BE GENERATED: openssl rand -base64 32 ``` All Adyen keys are configured inside ERPNext's Adyen Settings DocType, not in the Express API `.env`.