Skip to content

Transaction Types

Every money movement in ProxPanel writes a row to the transactions table with a type string. There are 14 canonical types as of v1.0.552. Of these, 13 count as income (summed into the Dashboard’s “Total Income” cards and the Reports → Revenue page) and 3 are accounting-neutral — internal movements that do not represent revenue.

This page is the canonical reference. Whenever the Dashboard, Reports, the API, or an export disagrees about a number, walk the difference back through this table.

Type stringGo constantClassificationCreated in
newTransactionTypeNewIncome (MRR)internal/handlers/subscriber.go — Create
renewalTransactionTypeRenewalIncome (MRR)internal/handlers/subscriber.go — Renew
change_serviceTransactionTypeChangeServiceIncomeinternal/handlers/subscriber.go — ChangeService
service_change(legacy spelling, same constant in some paths)IncomeLegacy code paths pre-v1.0.300
static_ipTransactionTypeStaticIPIncomeinternal/handlers/billing.go — Static IP rental
addonTransactionTypeAddonIncomeinternal/handlers/subscriber.go — Addon
refillTransactionTypeRefillIncomeinternal/handlers/subscriber.go — Refill quota
data_topup(uses refill constant in some flows; emitted by topup endpoint)Incomeinternal/handlers/customer_portal.go — Top-Up
prepaid_cardTransactionTypePrepaidCardIncomeinternal/handlers/prepaid.go — Card redemption
subscriber_topupTransactionTypeSubscriberTopupIncomeinternal/handlers/customer_portal.go — TopUp
subscriber_purchaseTransactionTypeSubscriberPurchaseIncomeinternal/handlers/customer_portal.go — Plan change
reset_fupTransactionTypeResetFUPIncomeinternal/handlers/subscriber.go — Reset FUP (paid)
renameTransactionTypeRenameIncomeinternal/handlers/subscriber.go — Rename (paid)
transferTransactionTypeTransferNeutralinternal/handlers/reseller.go — Transfer balance
withdrawTransactionTypeWithdrawNeutralinternal/handlers/reseller.go — Withdraw
refundTransactionTypeRefundNeutralinternal/handlers/subscriber.go — Delete with refund

Revenue types (counted in Dashboard Total Income)

Section titled “Revenue types (counted in Dashboard Total Income)”

13 transaction types count as income. They appear in:

  • Dashboard → Today’s Total Income card.
  • Dashboard → Month Total Income card.
  • Reports → Revenue.
  • Reports → Per-Service revenue.
  • Reports → Per-Reseller revenue.
TypeWhen emittedAmount signNotes
newA subscriber is created with a paying service+Counted in MRR (Subscription Revenue card).
renewalAn existing subscriber renews their plan+Counted in MRR. Auto-renewal also emits this.
change_serviceSubscriber moves from one plan to another mid-cycle+Amount may be a prorated charge or credit.
service_changeSame as change_service — legacy spelling+Treated identically.
static_ipStatic IP rental is started or renewed+One row per rental period.
addonA paid add-on is attached to the subscriber+E.g. premium support, fixed IP add-on.
refillSubscriber’s monthly quota is manually refilled (paid)+Distinct from free admin refill.
data_topupSubscriber buys extra GB via the customer portal+Tracked separately from subscriber_topup for legacy reasons.
prepaid_cardSubscriber redeems a prepaid voucher+Revenue is recognised at redemption, not card generation.
subscriber_topupTop-up via customer portal+Common name “data top-up” in some UI strings.
subscriber_purchaseCustomer-portal self-service plan change+Treated as a sale to the subscriber by their reseller.
reset_fupSubscriber pays to reset their FUP early+Service must have reset_price > 0.
renameSubscriber pays to change their PPPoE username+Often used in legacy migrations.
SELECT COALESCE(SUM(amount), 0) AS total_income
FROM transactions
WHERE created_at >= $1
AND created_at < $2
AND type IN (
'new', 'renewal', 'change_service', 'service_change',
'static_ip', 'addon', 'refill', 'data_topup',
'prepaid_card', 'subscriber_topup', 'subscriber_purchase',
'reset_fup', 'rename'
);

This is the exact list used by both the Dashboard and Reports as of v1.0.552. Older versions may compute differently — see Changelog → v1.0.552.

3 types are accounting movements. They appear in the Transactions list (for audit) but never count as income.

TypeWhen emittedAmount signWhat it represents
transferReseller moves balance to a sub-reseller, or to a peervariesA bookkeeping move — money switches accounts but no revenue.
withdrawReseller balance is withdrawn (typically by admin clawing back)Inverse of add_money (which is itself logged via audit, not a transaction).
refundSubscriber is deleted with refund, or a charge is reversedReturns money to the reseller’s balance.

Including transfers in revenue would double-count: the receiving reseller’s later renewal already counts. Withdrawals are negative; if summed with revenue, they’d subtract from MRR and break dashboards. Refunds are tracked separately on the Reports page under “Refunds” so accountants can see net revenue (income − refunds).

Subscription Revenue (MRR) vs Total Income

Section titled “Subscription Revenue (MRR) vs Total Income”

The Dashboard exposes both views via separate cards as of v1.0.552:

CardIncludesExcludes
Today’s Subscriptions / Month Subscriptionsnew, renewal onlyEverything else
Today’s Total Income / Month Total IncomeAll 13 income typestransfer, withdraw, refund

The Subscriptions cards are the canonical MRR metric — forecasting / valuation use them. Total Income matches cashflow and is what accountants reconcile against bank statements.

FieldMeaning
amountDecimal(15,2). Positive for charges TO the subscriber / reseller, negative for refunds, withdrawals, internal credits.
balance_before / balance_afterReseller balance snapshot before / after the transaction. Used by the Transactions list to render the running balance column.
reseller_idThe reseller whose balance moved. Always set.
subscriber_idSet for subscriber-attached types (new, renewal, refill, etc.); NULL for transfer between resellers.
target_reseller_idSet only on transfer rows — points at the receiving reseller.
service_nameFor new / renewal: the service name at the time of the transaction. Captured because the service may be renamed later.
old_service_name / new_service_nameFor change_service / service_change: before / after service names.
descriptionFree-form human-readable note.

Why doesn’t Reports → Revenue match my dashboard?

Section titled “Why doesn’t Reports → Revenue match my dashboard?”

Three possible causes:

  1. Time window mismatch — Dashboard uses local timezone midnight (system_preferences.system_timezone); some old API binaries used UTC. Confirm both pages use the same tz.
  2. Pre-v1.0.552 binary — Older Dashboard versions only counted new + renewal (MRR), while Reports counted all 13. Upgrade to v1.0.552 or later to align them.
  3. Reseller scope filter — Reports lets you filter by reseller. The Dashboard sums everything within the caller’s permission scope.

They don’t go in the transactions table. Reseller balance top-ups by admins are logged via the audit_logs table (action = reseller.add_money) and adjust resellers.balance directly. This is intentional — adding money to a reseller’s balance isn’t income, it’s the operator funding their downstream.

Customer-portal top-ups vs admin refills — what’s the difference?

Section titled “Customer-portal top-ups vs admin refills — what’s the difference?”
ActionType emittedSourceCounts as income?
Admin clicks “Refill Quota” in subscriber editrefillsubscribers.refill_quota permissionYes (paid) or No (free) — depends on whether admin checked “charge reseller”
Subscriber buys top-up via customer portalsubscriber_topupcustomer_portal.go TopUpYes
Subscriber redeems voucherprepaid_cardprepaid.go redemptionYes

Why do I see service_change rows in old data and change_service in new data?

Section titled “Why do I see service_change rows in old data and change_service in new data?”

The constant was renamed in v1.0.300 to standardise on category_action (matching the rest of the type strings). Both spellings are present in the SQL filter so they’re summed correctly. If you need a clean export, alias them in your query:

SELECT CASE type
WHEN 'service_change' THEN 'change_service'
ELSE type
END AS type_normalised,
SUM(amount) AS total
FROM transactions
GROUP BY 1;

Three rows are written when a subscriber is deleted with refund enabled:

  1. refund transaction — negative amount, reseller’s balance increases (refund is credit-back).
  2. audit_logs.action = 'subscriber.delete' — the deletion record.
  3. subscribers.deleted_at — set to now.

The receivable refund value comes from the original new/renewal transaction’s amount minus pro-rata for time already used. The exact formula lives in handlers/subscriber.go Delete.

Only deleted, never edited. transactions.delete permission gates the action. Edits would break the running-balance audit chain — to correct an error, create a compensating transaction (typically a manual refund or transfer) instead.

Most transactions are written synchronously as part of the corresponding handler request:

HTTP POST /api/subscribers/123/renew
└── handlers/subscriber.go Renew()
├── Compute charge
├── INSERT transaction (type=renewal) ◄── Transaction row created
├── UPDATE resellers SET balance = balance - charge
├── UPDATE subscribers SET expiry_date = ...
└── audit_logs.INSERT (subscriber.renew)

Auto-renewal is the exception — it’s driven by a background scheduler that walks subscribers with auto_renew=true AND expiry_date <= now(), emits the same renewal transaction, and updates the same balance.

CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
balance_before DECIMAL(15,2),
balance_after DECIMAL(15,2),
description VARCHAR(500),
old_service_name VARCHAR(100),
new_service_name VARCHAR(100),
service_name VARCHAR(100),
reseller_id INTEGER NOT NULL,
subscriber_id INTEGER,
target_reseller_id INTEGER,
ip_address VARCHAR(50),
user_agent VARCHAR(255),
created_by INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_transactions_type ON transactions(type);
CREATE INDEX idx_transactions_reseller ON transactions(reseller_id);
CREATE INDEX idx_transactions_sub ON transactions(subscriber_id);
CREATE INDEX idx_transactions_created ON transactions(created_at);

This table is never soft-deleted and never truncated in normal operation — it’s the canonical financial audit trail.

If you introduce a new type:

  1. Add the constant in internal/models/billing.go:
    TransactionTypeYourNewType TransactionType = "your_new_type"
  2. Decide whether it counts as income. If yes, add it to the canonical SQL filter in:
    • internal/handlers/dashboard.go (Total Income query).
    • internal/handlers/report.go (Revenue report queries).
  3. Update the Reports per-service / per-reseller aggregations if the new type should appear there.
  4. Update this reference page.
  5. Confirm Dashboard + Reports show the same numbers after re-deploy.

Skipping step 2 is the most common cause of “the dashboard says X but Reports says Y” issues post-feature-add.