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