RFC-009: Dual Billing Model for One-Time Activation and Recurring Subscription
Overview
This document defines a billing and entitlement model that supports both:
- one-time activation purchases
- recurring monthly subscriptions
The current codebase already accepts ONE_TIME and SUBSCRIPTION payment requests, but both flows are still funneled through the same payment activation behavior. This RFC separates:
- payments as money movement records
- subscriptions as recurring billing agreements
- entitlements as product access decisions
This separation allows the application to keep the current one-time activation flow while adding a true recurring subscription lifecycle.
Problem Statement
Today the backend supports:
paymentType: "ONE_TIME" | "SUBSCRIPTION"subscriptionInterval: "MONTHLY"for subscription requests- payment creation through Xendit
- webhook-driven payment completion
- license activation after successful payment
However, the current implementation does not yet provide a real recurring subscription model:
SUBSCRIPTIONcurrently uses the same QRIS payment creation path asONE_TIME- there is no subscription record or lifecycle state
- there is no billing period tracking
- there is no renewal or expiration behavior
- access is effectively modeled as a license activation side effect of payment success
As a result, the codebase currently supports:
- one-time activation well enough for the current product
- subscription-shaped payment creation, but not subscription management
Goals
- support a permanent one-time activation flow
- support a recurring monthly subscription flow
- preserve the current tenant-paid / booth-user-free business model
- keep payment processing and entitlement logic separate
- make license verification depend on entitlement state rather than payment side effects alone
- support webhook-driven lifecycle transitions
- allow future extension to additional plans or intervals without redesigning the model again
Non-Goals
- building a customer-facing billing portal in this RFC
- implementing payout or disbursement features
- supporting arbitrary billing intervals beyond monthly
- replacing Xendit immediately with another provider
- redesigning booth UX unrelated to billing or entitlement checks
Current State Summary
What Already Exists
- payment creation endpoint:
POST /api/v1/payments/create - payment status endpoint:
GET /api/v1/payments/status/:id - payment webhook endpoint:
POST /api/v1/payments/webhook - refund endpoint:
POST /api/v1/payments/:id/refund - license verification endpoint:
GET /api/v1/license/verify/:key - payment state transitions and idempotent webhook handling
Current Limitation
The current flow is effectively:
This is sufficient for a perpetual activation purchase, but not for a real recurring subscription where access must depend on subscription status over time.
Proposed Domain Model
The billing system should explicitly model three separate concerns:
-
Payments
- represent attempts to collect money
- may be one-time or linked to a subscription
- are never the source of truth for long-term product access
-
Subscriptions
- represent a recurring billing agreement
- track billing cadence, lifecycle, and renewal windows
- may exist before, during, or after individual payment attempts
-
Entitlements
- represent whether a tenant is allowed to use paid product features
- may be perpetual or time-bounded
- are what the booth checks before enabling usage
Billing Model
Purchase Types
Two purchase types are supported:
ONE_TIMESUBSCRIPTION
Entitlement Types
Two entitlement shapes are supported:
PERPETUALRECURRING
Core Rule
- a successful one-time payment grants a perpetual entitlement
- a successful subscription payment activates or renews a recurring entitlement
Proposed Architecture
Data Model Changes
Existing payments Table
The existing payments table remains, but its responsibility becomes:
- storing transaction attempts
- storing provider payment identifiers
- storing payment and refund status
- linking to subscriptions when relevant
New optional columns should be added:
ALTER TABLE payments ADD COLUMN purchase_type TEXT NOT NULL DEFAULT 'ONE_TIME';
ALTER TABLE payments ADD COLUMN subscription_id TEXT;
ALTER TABLE payments ADD COLUMN billing_period_start DATETIME;
ALTER TABLE payments ADD COLUMN billing_period_end DATETIME;
New subscriptions Table
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
provider_subscription_id TEXT,
plan_code TEXT NOT NULL,
interval TEXT NOT NULL DEFAULT 'MONTHLY',
status TEXT NOT NULL,
started_at DATETIME,
current_period_start DATETIME,
current_period_end DATETIME,
cancel_at DATETIME,
canceled_at DATETIME,
ended_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
Recommended statuses:
PENDINGACTIVEPAST_DUECANCELEDEXPIRED
New entitlements Table
CREATE TABLE entitlements (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
entitlement_type TEXT NOT NULL,
status TEXT NOT NULL,
starts_at DATETIME,
ends_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
Recommended fields:
source_type:ONE_TIME_PAYMENTorSUBSCRIPTIONentitlement_type:PERPETUALorRECURRINGstatus:ACTIVEorINACTIVE
Access Resolution Rules
The booth should stop treating “license activated” as the only access signal.
Instead, access should be granted if any of the following are true:
- the customer has an active perpetual entitlement
- the customer has an active recurring entitlement whose current time is within the valid period
Recommended resolution algorithm:
function hasActiveAccess(now: Date, entitlements: Entitlement[]): boolean {
return entitlements.some((entitlement) => {
if (entitlement.status !== "ACTIVE") return false;
if (entitlement.entitlementType === "PERPETUAL") {
return true;
}
return (
entitlement.startsAt !== null &&
entitlement.endsAt !== null &&
entitlement.startsAt <= now &&
now < entitlement.endsAt
);
});
}
Purchase Flows
Flow A: One-Time Activation
Result:
- entitlement type:
PERPETUAL - no renewal process
- refund can deactivate the entitlement if business rules require it
Flow B: Subscription Activation
Result:
- subscription becomes
ACTIVE - entitlement type:
RECURRING ends_atequals the end of the current billing period
Flow C: Subscription Renewal
API Changes
Keep Existing Endpoint
POST /api/v1/payments/create
This endpoint remains available, but its response should more clearly describe the resulting billing object:
{
"id": "pay_123",
"purchaseType": "ONE_TIME",
"status": "PENDING",
"qrString": "..."
}
{
"id": "pay_456",
"purchaseType": "SUBSCRIPTION",
"subscriptionId": "sub_123",
"status": "PENDING",
"subscriptionInterval": "MONTHLY",
"qrString": "..."
}
Add Subscription Read APIs
Recommended new endpoints:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/subscriptions/:id | Get subscription details |
| GET | /api/v1/subscriptions/customer/:id | List customer subscriptions |
| POST | /api/v1/subscriptions/:id/cancel | Request cancellation |
| POST | /api/v1/subscriptions/:id/resume | Resume a canceling or paused flow |
Keep License Verification, Change Its Source of Truth
GET /api/v1/license/verify/:key
This endpoint can remain unchanged externally, but internally it should resolve access from entitlement state rather than from payment activation alone.
Provider Integration Strategy
One-Time
- continue using current QRIS payment creation
- webhook remains authoritative for payment completion
Subscription
The current createMonthlySubscription() method must stop being an alias to one-time QRIS creation.
It should be upgraded to either:
- a true recurring billing integration with Xendit, or
- a transitional model where each renewal is explicitly created and tracked as a subscription renewal payment
Transitional Recommendation
If recurring provider support is not ready immediately:
- create a real
subscriptionstable now - treat the first payment as subscription activation
- create a renewal orchestration job later
This allows the data model and access logic to be correct even before full provider automation is implemented.
Webhook Handling Changes
Webhook processing should branch by purchase type and subscription linkage.
One-Time Webhook Success
- payment
PENDING -> COMPLETED - create or activate perpetual entitlement
Subscription Webhook Success
- payment
PENDING -> COMPLETED - activate subscription if first payment
- extend current period if renewal payment
- create or extend recurring entitlement
Failed Renewal
- mark renewal payment failed
- mark subscription
PAST_DUE - expire recurring entitlement after grace policy is reached
Refund Rules
Refund remains supported, but behavior should differ by source:
One-Time Refund
- mark payment refunded
- deactivate perpetual entitlement if refund means purchase reversal
Subscription Refund
- mark payment refunded
- determine whether current billing period should be revoked immediately or remain active until period end
This policy should be explicit and not implied.
Recommended initial policy:
- one-time refund: deactivate entitlement immediately
- subscription refund: deactivate only if the refunded payment is the payment that granted the current active period
Migration Strategy
Phase 1: Data Model Preparation
- add
purchase_typeandsubscription_idtopayments - create
subscriptionstable - create
entitlementstable
Phase 2: One-Time Flow Preservation
- move one-time activation to create a perpetual entitlement
- keep external one-time flow unchanged
Phase 3: Subscription Domain Introduction
- create subscriptions for
SUBSCRIPTIONpurchases - link initial payments to subscriptions
- grant recurring entitlements with
starts_atandends_at
Phase 4: Access Resolution Refactor
- update license verification and booth gating to use entitlements
Phase 5: Renewal and Cancellation
- add subscription renewal processing
- add cancellation and expiration policies
Risks
- additional domain complexity compared to the current simple license model
- incorrect entitlement resolution could lock out paying customers
- provider constraints may differ between QRIS and true recurring billing support
- legacy assumptions in UI or tests may treat license activation as permanent
Success Criteria
- one-time activation continues to work without regression
- monthly subscriptions can be represented as first-class records
- access checks work for both perpetual and recurring entitlements
- completed subscription payments extend access only for the correct billing period
- failed subscription renewals can suspend or expire access without affecting perpetual customers
Related Files
- RFC-001: RFC-001-payment-integration.md
- RFC-007: RFC-007-d1-database.md
- ADR-003: ADR-003-payment-integration.md