ForhoppPay Connect — Merchant of Record Recurring Billing
Feature Reference: FR-5 (MoR) | Services:
SubscriptionBillingService,BillingCycleScheduler,DunningService,SubscriptionPayoutService,SubscriptionTaxService
1. Overview
ForhoppPay Connect operates as the Merchant of Record (MoR) for recurring transactions. This means ForhoppPay is the seller of record: it collects payments from end customers, handles tax obligations, manages dunning for failed payments, and remits net revenue to the merchant after deducting platform fees and tax.
2. MoR vs Direct Model
| Aspect | MoR (ForhoppPay) | Direct (merchant collects) |
|---|---|---|
| Seller of record | ForhoppPay | Merchant |
| Tax collection | ForhoppPay handles | Merchant handles |
| Chargeback liability | Shared | Merchant |
| Payment method vault | ForhoppPay manages | Merchant manages |
| Compliance (PCI-DSS) | ForhoppPay | Merchant |
| Merchant receives | Net (total - fee - tax) | Gross (collects directly) |
3. Billing Cycle Scheduler
3.1 Configuration
Runs every hour at :00 via Spring @Scheduled(fixedRate = 3600000).
3.2 Flow
BillingCycleScheduler.processBillingCycle()
│
├─ Query: SELECT * FROM merchant_subscriptions
│ WHERE status = 'ACTIVE'
│ AND current_period_end < NOW()
│ FOR UPDATE SKIP LOCKED
│ LIMIT 100
│
For each subscription:
│
├─ SubscriptionBillingService.billSubscription(subscription)
│ │
│ ├─ Build consolidated invoice (items + prorations)
│ ├─ Calculate tax via SubscriptionTaxService
│ ├─ Create SubscriptionInvoice (status: OPEN)
│ │
│ ├─ Attempt charge:
│ │ ├─ TEST mode → SandboxPaymentSimulator
│ │ └─ LIVE mode → PayPal reference transaction
│ │
│ ├─ On SUCCESS:
│ │ ├─ Invoice status → PAID
│ │ ├─ Advance period (start = old end, end = old end + interval)
│ │ ├─ Credit merchant wallet via SubscriptionPayoutService
│ │ ├─ Mark pending prorations as BILLED
│ │ └─ Dispatch invoice.paid webhook
│ │
│ └─ On FAILURE:
│ ├─ Subscription status → PAST_DUE
│ ├─ Create DunningAttempt (attempt 1, retry in 3 days)
│ └─ Dispatch invoice.payment_failed webhook
│
└─ Also process trial-ended subscriptions:
├─ Query trialing subs where trial_end < NOW()
└─ Transition to ACTIVE and bill immediately
3.3 Multi-Pod Safety
FOR UPDATE SKIP LOCKED ensures that in a Kubernetes deployment with multiple pods, each pod processes different subscriptions. No duplicate billing can occur.
4. Invoice Generation
4.1 Invoice ID Format
si_[a-f0-9]{32}
4.2 Invoice Fields
| Field | Description |
|---|---|
invoice_id |
Public ID (si_xxx) |
subscription_id |
Link to subscription |
merchant_account_id |
Merchant receiving revenue |
customer_id |
Customer being billed |
subtotal_cents |
Sum of line items before tax |
tax_cents |
Tax amount |
total_cents |
subtotal + tax |
status |
DRAFT, OPEN, PAID, UNCOLLECTIBLE, VOID |
period_start / period_end |
Billing period covered |
attempt_count |
Number of charge attempts |
next_attempt_at |
When next retry scheduled (if failed) |
4.3 Invoice Statuses
| Status | Description |
|---|---|
DRAFT |
Being built (not yet charged) |
OPEN |
Charge attempted or scheduled |
PAID |
Payment collected successfully |
UNCOLLECTIBLE |
All retries exhausted |
VOID |
Manually voided |
5. Tax Calculation
5.1 Tax Rate Resolution
Priority order:
- Explicit
tax_rate_idon subscription → use that rate - Customer address auto-detect → match jurisdiction
- None configured → 0% (no tax)
5.2 Inclusive vs Exclusive
| Mode | Formula |
|---|---|
| Exclusive | total = subtotal + (subtotal × rate) |
| Inclusive | tax = total - (total / (1 + rate)) |
5.3 Tax Rate API
POST /api/v1/connect/tax-rates
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
"percentage": "8.875",
"display_name": "NY Sales Tax",
"jurisdiction": "US-NY",
"inclusive": false,
"country": "US",
"state": "NY"
}
6. Dunning Service
6.1 Retry Schedule
| Attempt | Timing | Action |
|---|---|---|
| Initial charge | Billing cycle | First attempt |
| Retry 1 | +3 days | Charge saved payment method |
| Retry 2 | +5 days (day 8) | Charge saved payment method |
| Retry 3 | +7 days (day 15) | Last attempt |
6.2 After All Retries Fail
- Subscription status →
UNPAID - After 30 days in
UNPAID→ auto-cancel with reasonpayment_failed subscription.canceledwebhook dispatched
6.3 Immediate Retry on Payment Method Update
If a customer updates their payment method while the subscription is past_due, the dunning service immediately retries the charge. On success, the subscription transitions back to ACTIVE.
7. Merchant Payout (SubscriptionPayoutService)
When a subscription charge succeeds, the merchant is credited:
Net = Total - Platform Fee - Tax
Platform Fee = round(total_cents × 0.029 + 30)
Credits go to the merchant's INVOICE wallet with ReferenceType.SUBSCRIPTION. The existing MerchantPayoutService picks these up during scheduled payouts.
Example
Customer charged: $20.00 (2000 cents)
Platform fee: $0.88 (88 cents)
Tax: $0.00
Net to merchant: $19.12 (1912 cents)
8. Trial Handling
If a price has trial_period_days configured:
- Subscription starts with status
TRIALING trial_startandtrial_endare set- No charges during trial
- 3 days before trial ends:
subscription.trial_will_endwebhook - When trial ends: transition to
ACTIVE, bill immediately
9. Customer Portal
Merchants can create portal sessions for customers to manage their subscriptions:
POST /api/v1/connect/portal-sessions
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
"customer_id": "cus_xxx",
"return_url": "https://merchant.com/account"
}
Portal allows customers to:
- Update default payment method
- Cancel subscription (at period end)
- View billing history
Portal URL format: https://pay.forhopp.com/portal/{sessionToken}
Sessions expire after 1 hour.
10. Notification Schedulers
TrialEndNotificationScheduler
- Runs daily
- Finds subscriptions in TRIALING where trial ends within 3 days
- Dispatches
subscription.trial_will_endwebhook
UpcomingInvoiceScheduler
- Runs daily
- Finds active subscriptions where next charge is within 3 days
- Dispatches
invoice.upcomingwebhook
11. Plans and Prices API
Plans
POST /api/v1/connect/plans
GET /api/v1/connect/plans
GET /api/v1/connect/plans/{planId}
Prices
POST /api/v1/connect/plans/{planId}/prices
Price fields:
| Field | Description |
|---|---|
amount_cents |
Charge amount per interval (min 50) |
currency |
Currency code |
interval |
daily, weekly, monthly, yearly |
interval_count |
Every N intervals (e.g., 2 = every 2 months) |
trial_period_days |
Days of free trial before first charge |
12. Customers API
POST /api/v1/connect/customers
GET /api/v1/connect/customers
GET /api/v1/connect/customers/{customerId}
PATCH /api/v1/connect/customers/{customerId}
Customer fields: email, name, metadata. Unique per (merchant + email + environment).
13. Payment Method Vault
POST /api/v1/connect/customers/{customerId}/payment-methods
GET /api/v1/connect/customers/{customerId}/payment-methods
DELETE /api/v1/connect/customers/{customerId}/payment-methods/{pmId}
Payment methods are stored as PaymentMethodToken records with encrypted gateway tokens (AES-256-GCM). Supports PayPal billing agreements for recurring charges.
14. Correctness Properties
| # | Property | Validates |
|---|---|---|
| 49 | Billing scheduler uses FOR UPDATE SKIP LOCKED (no duplicate billing) | FR-5.1 |
| 50 | Failed charge transitions to PAST_DUE with dunning | FR-5.2, FR-6.1 |
| 51 | Net payout = total - fee - tax (never negative) | FR-9.1, FR-9.2 |
| 52 | Trial-ended subscriptions transition to ACTIVE and bill | FR-4.3 |
Last Updated: June 2026
