Skip to main content

Billing Service

This is the reference page for lumie-backend/modules/billing, the platform billing module that owns plans, subscriptions, billing keys, invoices, payment audit trails, and Alimtalk credits.

Source Paths

PathRole
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/web/*.javaPublic HTTP surface for plans, billing keys, payments, subscriptions, credits, and the Toss webhook receiver
lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/BillingSubscribeService.javaOrchestrates the paid-plan subscribe flow with card registration, subscription mutation, and first charge
lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/SubscriptionCommandService.javaSubscription lifecycle commands, trial provisioning, upgrades, downgrades, cancellations, and first-charge entrypoint
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/internal/BillingServiceAdapter.javaIn-process billing API consumed by other modules through libs/internal-api
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/event/TenantCreatedListener.javaAFTER_COMMIT listener that provisions the trial subscription when tenant creation finishes
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/scheduling/*.javaMonthly charge and scheduled transition jobs protected by ShedLock
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/out/external/{TossPaymentClient,PopbillTaxInvoiceClient}.javaExternal payment and tax-invoice adapters
lumie-backend/modules/billing/src/main/resources/db/migration/V1__create_billing_tables.sqlModule-local bootstrap for the original billing schema
lumie-backend/app/src/main/resources/db/migration/public/{V28__billing_platform_tables,V31__billing_add_missing_columns,V33__billing_keys_add_customer_key,V37__subscription_scheduled_changes,V38__create_billing_operation_locks,V39__subscription_scheduled_change_indexes}.sqlCurrent public-schema source of truth for billing tables and scheduling metadata

Public Surface

EndpointPurpose
GET /v1/plans, GET /v1/plans/{planId}List plan metadata and fetch a single plan
GET /v1/billing/configReturn browser-safe billing config such as the Toss client key
POST /v1/billing-keys, GET /v1/billing-keys, GET /v1/billing-keys/active, DELETE /v1/billing-keys/{billingKeyId}Register, inspect, and revoke stored billing keys
POST /v1/payments/confirm, GET /v1/payments/history, GET /v1/payments/history/{tenantSlug}Confirm one-time Toss payments and read invoice history
POST /v1/billing/subscribeSingle-shot “register card + switch plan + charge first invoice” flow
POST /v1/subscriptions, GET /v1/subscriptions/{tenantSlug}, PATCH /v1/subscriptions/{tenantSlug}, DELETE /v1/subscriptions/{tenantSlug}, DELETE /v1/subscriptions/{tenantSlug}/cancellation, POST /v1/subscriptions/{tenantSlug}/chargeSubscription lifecycle management
GET /v1/subscriptions/{tenantSlug}/quota/{metricType}Quota check surface exposed to callers even though enforcement is currently bypassed
GET /v1/alimtalk/credits, POST /v1/alimtalk/credits/recharge, PUT /v1/alimtalk/credits/auto-recharge, DELETE /v1/alimtalk/credits/auto-rechargeRead and mutate Alimtalk credit balance and auto-recharge config
POST /internal/webhooks/toss/billingVerify and parse Toss billing webhooks inside the monolith

All mutating public billing endpoints require Idempotency-Key except the Toss webhook receiver.

Internal Surface And Dependencies

SurfaceReality in code
lumie-backend/libs/internal-api/src/main/java/com/lumie/billing/api/BillingService.javaPublished in-process contract for subscription lookup, plan features, quota check, and free-subscription provisioning
BillingServiceAdapterImplements the published contract and also contains extra helper methods such as getActiveSubscription(...) and consumeAlimtalkCredit(...) that are not part of the published interface
TenantCreatedEventEmitted by the tenant module and consumed by TenantCreatedListener to provision the trial subscription after commit
PaymentGatewayPortAbstraction for Toss billing-key issuance, one-time payment confirmation, charge, cancel, and webhook verification
TaxInvoicePortAbstraction for tax-invoice issuance backed by the Popbill adapter

Aggregates And Tables

AggregateNotes
PlanPublic plan catalog with limits and features
SubscriptionPlatform-scoped subscription aggregate with states such as ACTIVE, PAUSED, PAST_DUE, CANCELLED, and EXPIRED
BillingKeyStored card/billing key state per tenant
InvoiceSubscription billing invoices and payment confirmation state
PaymentTransactionImmutable payment gateway audit log used for request, response, and webhook capture
AlimtalkCreditTenant-scoped message-credit balance and auto-recharge configuration
TaxInvoiceTax-invoice issuance record and failure metadata

Runtime Flow

Tenant signup runs a second billing flow through TenantCreatedListener: the tenant module emits TenantCreatedEvent, and billing provisions a trial PRO subscription after commit.

Contract Notes

The subscription aggregate contains the real state machine, including trial expiry, pending downgrades, cancel-at-period-end, and auto-pause after repeated charge failures.

// lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/internal/BillingServiceAdapter.java
@Override
public QuotaResult checkQuota(String tenantSlug, MetricType metricType) {
return new QuotaResult(true, 0, Long.MAX_VALUE, metricType,
"Billing integration pending - unlimited access granted");
}

The quota contract exists, but runtime enforcement is currently disabled. Callers should not treat the quota endpoint as an active limiter yet.

Example Contracts

These examples come directly from BillingSubscribeController, SubscribeRequest, SubscribeResponse, TossBillingWebhookController, and TossBillingWebhookPayload.

Subscribe To A Paid Plan

POST /v1/billing/subscribe
Idempotency-Key: subscribe-01
Content-Type: application/json

{
"authKey": "bln_abc",
"customerKey": "lumie-tenant-15",
"planId": "PRO"
}
HTTP/1.1 200 OK

{
"subscription": {
"id": 1,
"tenantSlug": "demo",
"planId": "PRO",
"status": "ACTIVE",
"billingKeyId": 99
},
"billingKey": {
"id": 99,
"maskedCardNumber": "1234-****-****-5678",
"cardCompany": "현대",
"status": "ACTIVE"
},
"invoice": {
"id": 500,
"invoiceNumber": "INV-15-20260528-AB",
"amount": 99000,
"vatAmount": 0
}
}

BillingSubscribeControllerTest asserts the externally consumed fields subscription.planId, billingKey.id, and invoice.amount.

Toss Billing Webhook

POST /internal/webhooks/toss/billing
Toss-Signature: t=1712345678,v1=<hmac-hex>
Content-Type: application/json

{
"eventType": "PAYMENT.STATUS_CHANGED",
"createdAt": "2026-06-14T12:34:56Z",
"data": {
"orderId": "INV-15-20260528-AB",
"paymentKey": "pay_123",
"billingKey": "bill_123",
"customerKey": "lumie-tenant-15",
"status": "DONE",
"totalAmount": 99000
}
}
HTTP/1.1 200 OK

TossBillingWebhookController only verifies the signature, parses TossBillingWebhookPayload, logs eventType, and returns 200. The TODO dispatch means a successful webhook parse is not yet a billing-state mutation.

Failure, Retry, And Observability

  • BillingSubscribeService serializes concurrent per-tenant subscribe flows through billing_operation_locks; a competing request gets 409 OPERATION_IN_PROGRESS.
  • Subscription.chargeAttemptFailed() moves subscriptions to PAST_DUE and auto-pauses them after three consecutive failures.
  • MonthlySubscriptionChargeScheduler and SubscriptionScheduledChangeScheduler run under ShedLock, so only one pod processes those jobs at a time.
  • TenantCreatedListener is a Spring Modulith AFTER_COMMIT listener. If trial provisioning fails, the event publication remains incomplete and is retried on restart.
  • PaymentTransaction is the main audit trail for payment gateway request and response payloads.
  • TossBillingWebhookController currently verifies the signature and parses TossBillingWebhookPayload, but it still contains a dispatch TODO; no downstream billing mutation happens from that webhook yet.

Verification

cd lumie-backend
./gradlew :modules:billing:test
./gradlew :modules:billing:test --tests '*BillingSubscribeControllerTest'
./gradlew :modules:billing:test --tests '*TenantCreatedListenerTest'

Expected success signals:

  • Gradle exits with BUILD SUCCESSFUL, and BillingSubscribeControllerTest still asserts $.subscription.planId, $.billingKey.id, and $.invoice.amount.
  • TossBillingWebhookController still returns 200 OK after successful verification and parse, while the inline TODO confirms webhook dispatch is not wired yet.