Skip to main content

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:

  • SUBSCRIPTION currently uses the same QRIS payment creation path as ONE_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:

  1. 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
  2. Subscriptions

    • represent a recurring billing agreement
    • track billing cadence, lifecycle, and renewal windows
    • may exist before, during, or after individual payment attempts
  3. 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_TIME
  • SUBSCRIPTION

Entitlement Types​

Two entitlement shapes are supported:

  • PERPETUAL
  • RECURRING

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:

  • PENDING
  • ACTIVE
  • PAST_DUE
  • CANCELED
  • EXPIRED

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_PAYMENT or SUBSCRIPTION
  • entitlement_type: PERPETUAL or RECURRING
  • status: ACTIVE or INACTIVE

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:

  1. the customer has an active perpetual entitlement
  2. 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_at equals 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:

MethodEndpointDescription
GET/api/v1/subscriptions/:idGet subscription details
GET/api/v1/subscriptions/customer/:idList customer subscriptions
POST/api/v1/subscriptions/:id/cancelRequest cancellation
POST/api/v1/subscriptions/:id/resumeResume 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:

  1. a true recurring billing integration with Xendit, or
  2. 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 subscriptions table 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_type and subscription_id to payments
  • create subscriptions table
  • create entitlements table

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 SUBSCRIPTION purchases
  • link initial payments to subscriptions
  • grant recurring entitlements with starts_at and ends_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