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