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
| Path | Role |
|---|---|
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/web/*.java | Public 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.java | Orchestrates 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.java | Subscription 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.java | In-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.java | AFTER_COMMIT listener that provisions the trial subscription when tenant creation finishes |
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/in/scheduling/*.java | Monthly charge and scheduled transition jobs protected by ShedLock |
lumie-backend/modules/billing/src/main/java/com/lumie/billing/adapter/out/external/{TossPaymentClient,PopbillTaxInvoiceClient}.java | External payment and tax-invoice adapters |
lumie-backend/modules/billing/src/main/resources/db/migration/V1__create_billing_tables.sql | Module-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}.sql | Current public-schema source of truth for billing tables and scheduling metadata |
Public Surface
| Endpoint | Purpose |
|---|---|
GET /v1/plans, GET /v1/plans/{planId} | List plan metadata and fetch a single plan |
GET /v1/billing/config | Return 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/subscribe | Single-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}/charge | Subscription 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-recharge | Read and mutate Alimtalk credit balance and auto-recharge config |
POST /internal/webhooks/toss/billing | Verify 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
| Surface | Reality in code |
|---|---|
lumie-backend/libs/internal-api/src/main/java/com/lumie/billing/api/BillingService.java | Published in-process contract for subscription lookup, plan features, quota check, and free-subscription provisioning |
BillingServiceAdapter | Implements the published contract and also contains extra helper methods such as getActiveSubscription(...) and consumeAlimtalkCredit(...) that are not part of the published interface |
TenantCreatedEvent | Emitted by the tenant module and consumed by TenantCreatedListener to provision the trial subscription after commit |
PaymentGatewayPort | Abstraction for Toss billing-key issuance, one-time payment confirmation, charge, cancel, and webhook verification |
TaxInvoicePort | Abstraction for tax-invoice issuance backed by the Popbill adapter |
Aggregates And Tables
| Aggregate | Notes |
|---|---|
Plan | Public plan catalog with limits and features |
Subscription | Platform-scoped subscription aggregate with states such as ACTIVE, PAUSED, PAST_DUE, CANCELLED, and EXPIRED |
BillingKey | Stored card/billing key state per tenant |
Invoice | Subscription billing invoices and payment confirmation state |
PaymentTransaction | Immutable payment gateway audit log used for request, response, and webhook capture |
AlimtalkCredit | Tenant-scoped message-credit balance and auto-recharge configuration |
TaxInvoice | Tax-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
BillingSubscribeServiceserializes concurrent per-tenant subscribe flows throughbilling_operation_locks; a competing request gets409 OPERATION_IN_PROGRESS.Subscription.chargeAttemptFailed()moves subscriptions toPAST_DUEand auto-pauses them after three consecutive failures.MonthlySubscriptionChargeSchedulerandSubscriptionScheduledChangeSchedulerrun under ShedLock, so only one pod processes those jobs at a time.TenantCreatedListeneris a Spring Modulith AFTER_COMMIT listener. If trial provisioning fails, the event publication remains incomplete and is retried on restart.PaymentTransactionis the main audit trail for payment gateway request and response payloads.TossBillingWebhookControllercurrently verifies the signature and parsesTossBillingWebhookPayload, but it still contains a dispatchTODO; 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, andBillingSubscribeControllerTeststill asserts$.subscription.planId,$.billingKey.id, and$.invoice.amount. TossBillingWebhookControllerstill returns200 OKafter successful verification and parse, while the inlineTODOconfirms webhook dispatch is not wired yet.