Forhopp

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:

  1. Explicit tax_rate_id on subscription → use that rate
  2. Customer address auto-detect → match jurisdiction
  3. 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 reason payment_failed
  • subscription.canceled webhook 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_start and trial_end are set
  • No charges during trial
  • 3 days before trial ends: subscription.trial_will_end webhook
  • 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_end webhook

UpcomingInvoiceScheduler

  • Runs daily
  • Finds active subscriptions where next charge is within 3 days
  • Dispatches invoice.upcoming webhook

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

Was this page helpful?

Edit this page