Withdrawal Rules Manual
About This Page
What: All rules that affect withdrawal behavior — each one clearly states: what it is, why it exists, and where to change it for requirement changes Audience: Product managers who need to know "can this rule be changed, and how" Prerequisites: Withdrawal Methods OverviewReading time: 5 minutes Owner: Withdrawal Product Manager
Key Takeaway: Withdrawal rules fall into four major categories — approval templates (6 types), high-risk determination (6 factors), auto-withdrawal conditions (6 items), and three-tier limit system — each rule is annotated with its configuration location and change method.
Approval Templates
What: Based on the withdrawal source and risk determination, the system selects an approval template for each withdrawal task. The template determines how many approval steps the withdrawal requires.
| Template Key | Approval Steps | Applicable Scenario | Trigger Source |
|---|---|---|---|
withdraw_default | Confirm → Remittance | Normal withdrawal | User-initiated from App |
withdraw_unusual | Audit → Confirm → Remittance | Normal account abnormal withdrawal | HighRiskCheck upgrade |
withdraw_om | Confirm → Remittance | OM (Omnibus) account withdrawal | OM sub-account initiated |
withdraw_om_unusual | Audit → Confirm → Remittance | OM account abnormal withdrawal | HighRiskCheck upgrade |
fund | Confirm → Remittance | Cash Treasure/fund redemption withdrawal | Cash Treasure redemption/fund redemption callback |
fund_unusual | Audit → Confirm → Remittance | Cash Treasure/fund redemption abnormal withdrawal | HighRiskCheck upgrade |
Why: Most withdrawals go through 2 steps (Confirm → Remittance). Only withdrawals flagged as abnormal (unusual) get an extra Audit step — this applies not just to OM accounts but also to normal accounts and fund redemptions.
Note: Tasks are created with default/om/fund template by default. Only after HighRiskCheck executes may they be upgraded to the corresponding unusual template. Before HighRiskCheck completes, the user sees "Processing".
If requirements change: Modifying approval templates
Code location: withdraw/src/app/Business/Task.php → $stepTemplates array
Common change scenarios:
- Add a new template (e.g., VIP users skip Confirm) → Add new template key + Step array
- Remove OM distinction → Merge
withdraw_omandwithdraw_default - Add a new step to a template → Create new Step class (implementing
IFStepinterface), add to template array
Approval Steps and Permissions
What: Each withdrawal goes through up to three approval steps, each requiring different CRM permissions:
| Step | Name | Permission Code | When Needed |
|---|---|---|---|
| Step 1 | Audit | PERMISSION_CASH_TASKS_OUT_AUDIT | unusual template only |
| Step 2 | Confirm | PERMISSION_CASH_TASKS_OUT_CONFIRM | All withdrawals |
| Step 3 | Remittance | PERMISSION_CASH_TASKS_OUT_REMIT | All withdrawals |
Why: Withdrawal involves funds leaving, which is higher risk than deposit. Erroneous deposits can be reversed (money is still in our account), but erroneous withdrawals are difficult to recover once remitted. Multi-layer confirmation is a safety valve.
High-Risk Determination (6 Factors)
What: After a withdrawal task is created, the queue event HighRiskCheck automatically executes 6-factor detection. A bitmask is used to calculate the risk value, with each factor occupying one bit:
| Factor | Detection Content | Consequence When Hit |
|---|---|---|
| HIGH_RISK_USER | User risk level = 3 | Triggers Audit |
| HIGH_RISK_AREA | Receiving bank in high-risk region (includes PR, CN) | Triggers Audit |
| HIGH_RISK_FREQUENCY | >= 10 withdrawals at the same time point | Record only |
| HIGH_RISK_AMOUNT | Amount exceeds max_amount (BST only) | Record only |
| HIGH_RISK_FRAUDULENT | Anti-fraud detection fails | Triggers Audit |
| HIGH_RISK_SWIFT | SWIFT Code on risk list | Triggers Audit |
Bitmask technical details
The high_risk field in the task table stores using bitmask: USER=1, AREA=2, FREQUENCY=4, AMOUNT=8, FRAUDULENT=16, SWIFT=32. For example, high_risk = 3 means both USER(1) and AREA(2) were hit.
Why FREQUENCY and AMOUNT don't trigger Audit: These two factors already have independent safety mechanisms controlling them (10 transactions per day cap, three-tier limit system), so additional manual review is unnecessary.
If requirements change: Adjusting high-risk rules
Code location:
- Risk calculation:
withdraw/src/app/Business/HighRisk/HighRiskBiz.php→ variouscheckHighRisk*()methods - Audit determination:
withdraw/src/app/Business/Event/HighRiskCheck.php→needAudit()method
Common change scenarios:
- Add a new factor that triggers Audit → Add bit check in
needAudit() - Modify high-risk country list → Adjust API interface's returned country list (updated every 5 minutes)
- Make FREQUENCY also trigger Audit → Add
HIGH_RISK_FREQUENCYcheck inneedAudit() - CN (mainland) no longer considered high-risk → Remove from hardcoded country list in
HighRiskBiz.php'scheckHighRiskArea()
Monthly Review Process
In addition to real-time 6-factor detection, high-risk customers also undergo monthly reviews:
| Step | Action | Responsible Person | Deadline |
|---|---|---|---|
| 1 | Export all withdrawal tasks from last month with high_risk ≠ 0 | Withdrawal Ops | Before 5th of each month |
| 2 | Review transaction records, focusing on suspicious patterns below | Withdrawal Ops | Before 10th of each month |
| 3 | Compile suspicious records into a report, escalate to compliance team | Withdrawal Ops | Within 2 business days of discovery |
| 4 | Compliance team evaluates whether to escalate to SAR (Suspicious Activity Report) | Compliance Team | Within 5 business days of receipt |
Suspicious transaction pattern reference:
- Same user with frequent large withdrawals in short period (daily average amount surges 3x or more)
- Withdrawal destination changes frequently (switching multiple bank cards in short period)
- Quick withdrawal after deposit (deposit-to-withdrawal interval < 24 hours and amounts are close)
- Withdrawal destination is high-risk region (AREA factor hit) with abnormal amount
Auto-Withdrawal Conditions
What: At the Remittance step, BST channels check 6 conditions. All must pass for fully automated withdrawal; if any one fails, it degrades to manual approval:
| # | Condition | Description |
|---|---|---|
| 1 | Channel is auto_bs | Only BST supports full automation |
| 2 | Not migration data | Excludes historical data migrated from old system |
| 3 | Auto-withdrawal switch is ON | auto_settings.status independently controlled per currency |
| 4 | Amount within limits | Airstar HKD <= 3 million / USD <= 500K |
| 5 | Sufficient balance | Available balance >= withdrawal amount |
| 6 | No more than 10 per day | Degrades to manual when same user's daily count >= 10, prevents batch anomalies |
Why: These 6 conditions are a safety valve, not a blocker. Failing ≠ withdrawal failure, just degradation to manual — ops can still execute after confirmation. This logic is consistent with the 5 auto-crediting conditions for deposits.
Condition 4 is just the first tier of the three-tier limit
Even if a single transaction passes (condition 4), when the daily cumulative reaches the third tier (daily circuit breaker), subsequent withdrawals will also degrade to manual — this is not the "10 transactions" limit of condition 6, but the amount-dimension circuit breaker. See below.
Three-Tier Limit System
What: BST channels employ a three-tier limit control, progressively tightening from single transaction to global:
| Tier | Name | Function | Behavior When Triggered |
|---|---|---|---|
| Tier 1 | Single transaction cap (max) | Cap on single deposit/withdrawal amount | Exceeding directly rejected |
| Tier 2 | Cumulative alarm (alarm) | Cumulative amount reaches alarm threshold over a period | Triggers Feishu alert, does not block transaction |
| Tier 3 | Daily circuit breaker (stop) | Daily cumulative amount reaches circuit breaker line | Auto-withdrawal for that day closes, requires manual handling |
Airstar Limits (Known Values)
| Currency | Single Transaction Cap | Cumulative Alarm | Daily Circuit Breaker |
|---|---|---|---|
| HKD | 3 million | 40 million | 15 million |
| USD | 500K | 10 million | 3 million |
Airstar Online Account Opening Minimum Deposit
| Currency | Minimum Deposit |
|---|---|
| HKD | 10,000 |
| USD | 1,500 |
| CNH | 10,000 |
CMB / CMBC Limits
CMB and CMBC three-tier limit values are maintained at runtime in the auto_settings table. Operations rules are listed here:
| Rule | Description |
|---|---|
| Dual-person approval for large amounts | When single transaction HKD >= 3 million / USD >= 500K, 2 ops staff must click "Process" to confirm |
| Notify accounting | Large withdrawal confirmation must notify the accounting team |
| Query current values | SELECT * FROM auto_settings WHERE id IN (1, 2); (id=1 CMBC, id=2 CMB) |
Airstar limit data requires confirmation
The source document (funds configuration.xlsx) shows Airstar large amount thresholds as HKD 8 million / USD 2 million / CNH 8 million, which differs from the current documented HKD 3 million / USD 500K. Possible reasons: xlsx may be product planning values, current values are live configuration. Please confirm with engineering and update.
Why: The three-tier design balances efficiency and safety — small amounts fully auto, medium amounts alert, large amounts circuit break. More flexible than a simple "reject when exceeded".
Auto-Withdrawal Configuration (auto_settings)
What: The auto_settings table maintains independent auto-withdrawal configuration for each BST bank (CMB id=2, CMBC id=1, Airstar id=3), per currency.
Each currency has 6 fields (prefix hk_ / us_ / cn_ corresponding to HKD / USD / CNH):
| Field | Meaning | Example Value |
|---|---|---|
{prefix}status | Switch: OPEN(1) / CLOSE(0) | 1 |
{prefix}amount | Current auto-withdrawal balance (internal ledger) | 5,000,000 |
{prefix}max_amount | Single transaction cap (Tier 1) | 3,000,000 |
{prefix}alarm_amount | Balance alarm threshold (Tier 2) | 40,000,000 |
{prefix}stop_amount | Balance circuit breaker threshold (Tier 3) | 15,000,000 |
{prefix}alarm_time | Last alert time | 2026-04-28 10:00:00 |
Auto-Close Mechanism
Each auto-withdrawal execution deducts the withdrawal amount from amount. When balance falls below thresholds:
- Below alarm_amount → Sends Feishu alert notification to ops ("Auto-withdrawal balance low")
- Below stop_amount → Automatically closes the auto-withdrawal switch for that currency; all subsequent withdrawals degrade to manual
Important: Balance deduction happens before the actual bank transfer. Even if the bank later rejects the transfer, the balance has already been deducted. Ops must manually adjust to restore.
Operations Actions
| Action | Method |
|---|---|
| Check balance | Query auto_settings table for the corresponding bank and currency's {prefix}amount |
| Top up | Update {prefix}amount (increase amount), ensure {prefix}status = 1 |
| Emergency shutdown | Set {prefix}status to 0; all BST withdrawals for that currency immediately degrade to manual |
| Adjust limits | Update {prefix}max_amount, {prefix}alarm_amount, {prefix}stop_amount |
If requirements change
- Modify single transaction cap → Update
auto_settingstable's{prefix}max_amountfield - Add JPY/SGD auto-withdrawal → Need to add new prefix mapping in
withdraw/src/app/Business/AutoSetting.php
Fee Rules
What: Withdrawal fees are calculated based on bank card location and currency.
| Bank Card Location | HKD | USD | CNH |
|---|---|---|---|
| Hong Kong | Free | Free | Free |
| Mainland China | Not supported | ||
| United States | |||
| Other regions |
Currently all free
The code defines cross-border withdrawal fees (HKD 105, USD 15, CNH 105), but the last line $fee = 0 forces all fees to 0. i.e., currently all withdrawals are fee-free.
Channel Additional Fees
Although withdrawal fees are fully waived, some channels have bank-side fees:
| Channel | Fee | Borne By | Notes |
|---|---|---|---|
| CHATS/RTGS | USD $8 / HKD $25 / CNY $25 | User | User is prompted with popup when confirming withdrawal |
| FPS | 2.5 HKD/transaction | Futu | Futu bears the cost, user does not see it |
| Cross-border wire | Intermediary bank charges (uncontrollable) | User | Arrived amount may be less than requested amount |
Special restrictions:
- Velo Bank: Does not support withdrawing HKD and offshore RMB to Velo accounts (USD only)
- Mainland bank card + CNH: User-facing does not allow withdrawing offshore RMB to mainland bank cards, but CRM side is not subject to this restriction
If requirements change: Adjusting fees
Code location: withdraw/src/app/Business/CreatorBase.php → getFee() method
- Restore cross-border fees → Remove the trailing
$fee = 0 - Modify fee rates → Modify fee constants in
switchstatement - Add new currency fees → Add case in
switch(e.g., JPY, SGD) - Bank-specific free promotion → Add bank check branch at the beginning of
getFee()
Processing Hours
What: Auto-withdrawal does not run 24/7; it only executes during the bank's processing hours:
| Channel | Auto Processing Hours | Off-hours Behavior |
|---|---|---|
| BST (CMB) | Trading days 08:40 ~ 15:59 (HKD/USD); 08:40 ~ 10:59 (CNH / margin) | Queued for next trading day |
| BST (CMBC) | Trading days 08:40 ~ 15:59 (HKD/USD); CNH does not use BST (ops selects channel) | Queued for next trading day |
| BST (Airstar) | See detailed table below | Queued for next trading day |
| Online banking (HSBC/Hang Seng) | Business days 09:00 ~ 16:00 | Requires manual next-day processing |
| FPS | 24/7 | Around the clock (but ops approval still requires business days) |
Airstar BST Detailed Service Hours
Airstar service hours are differentiated across four dimensions: margin/non-margin and full-day/half-day market:
| Market Type | Account Type | Submission Window | Auto Withdrawal | Large Amount Criteria | Expected Arrival |
|---|---|---|---|---|---|
| Full-day | Margin | Trading day 09:00~10:55 | Auto | — | Within 5 minutes |
| Margin | Trading day 09:00~10:55 | Non-auto | HKD > 8 million / USD > 2 million | Before DD+1 15:55 | |
| Margin | Trading day 09:00~10:55 | Non-auto | <= large amount threshold | Before same day 15:55 | |
| Margin | Other times | Auto | — | Before DD+1 09:00 | |
| Margin | Other times | Non-auto | — | Before DD+1 15:55 | |
| Non-margin | Trading day 09:00~15:55 | Auto | — | Within 5 minutes | |
| Non-margin | Trading day 09:00~15:55 | Non-auto | HKD > 8 million / USD > 2 million | Before DD+1 15:55 | |
| Non-margin | Trading day 09:00~15:55 | Non-auto | <= large amount threshold | Before same day 15:55 | |
| Non-margin | Trading day 00:00~09:00 | Auto | — | Before same day 09:05 | |
| Non-margin | Other times | Auto | — | Before DD+1 09:00 | |
| Half-day | Margin | Trading day 09:00~10:00 | Auto | — | Within 5 minutes |
| Margin | Trading day 09:00~10:00 | Non-auto | HKD > 8 million / USD > 2 million | Before DD+1 15:55 | |
| Margin | Trading day 09:00~10:00 | Non-auto | <= large amount threshold | Before same day 12:00 | |
| Non-margin | Trading day 09:00~11:55 | Auto | — | Within 5 minutes | |
| Non-margin | Trading day 09:00~11:55 | Non-auto | HKD > 8 million / USD > 2 million | Before DD+1 15:55 | |
| Non-margin | Trading day 09:00~11:55 | Non-auto | <= large amount threshold | Before same day 12:00 |
Plain Language Explanation
- Margin account windows are shorter — because margin withdrawal involves margin calculation and must be completed before clearing, so it cuts off earlier (full-day 10:55 vs non-margin 15:55)
- Half-day market all deadlines are earlier — the exchange closes at noon, clearing follows (non-auto arrival deadline shrinks from 15:55 to 12:00)
- Non-trading days do not process any Airstar withdrawals — withdrawals submitted on weekends/public holidays are queued to the next trading day
- DD = next trading day (not calendar day); e.g., Friday submission may not process until Monday
Why: Even if all 6 auto-withdrawal conditions are met, if the submission time is outside processing hours, the withdrawal will queue. The user sees "Processing".
Trading Day ≠ Business Day
BST processing hours are based on HKEX trading days. Withdrawals on non-trading days (weekends, public holidays) queue until the next trading day.
Freeze Types
What: Freeze flags on a user's account restrict withdrawals. 4 freeze types:
| Code | Meaning | Impact on Withdrawal |
|---|---|---|
| 1 | Currency exchange freeze | Frozen amount cannot be withdrawn |
| 2 | Withdrawal freeze | Directly restricts withdrawal |
| 6 | Cash Treasure fund freeze | Frozen amount cannot be withdrawn |
| 7 | Margin withdrawal freeze | Margin-related funds frozen |
Anti-Duplication Mechanism
What: BST channels use triple identifiers to prevent duplicate deposits/withdrawals:
| Identifier | Source | Purpose |
|---|---|---|
procedure_id | SBA orchestration system | moomoo-side unique reference number |
request_id | Deposit/withdrawal service | Request-level deduplication |
bank_ref_id | Bank return | Bank-side unique reference number |
The system checks the combined uniqueness of three identifiers when creating instructions. When a duplicate is detected, it refuses to create a new instruction and returns the status of the existing one.
Processing Time Reference
| Channel | Fastest | Typical | Slowest |
|---|---|---|---|
| BST | Seconds | Minutes | Next trading day |
| Online banking (HSBC/Hang Seng) | Minutes | ~30 minutes | Hours |
| FPS (BOC/SCB/CGB) | Minutes | ~10 minutes | Hours |
| BOC same-bank / ICBC manual | Hours | Hours | Same day |
| CHATS/RTGS | Hours | Hours | Same day |
| Cross-border wire / EWB | 1 day | 2~3 days | 3~5 days |
| Cheque | 2 days | 3~5 days | 5+ days |
Common Misconceptions
| Misconception | Fact |
|---|---|
| "Auto-withdrawal conditions not met = withdrawal failed" | Not a failure, just degradation to manual mode. Ops can still execute normally after confirmation |
| "Three-tier limits are three walls, each one blocks" | No. Only Tier 1 (single transaction cap) rejects when exceeded; Tier 2 (cumulative alarm) only sends Feishu alert without blocking; Tier 3 (daily circuit breaker) closes auto-withdrawal |
| "High risk = withdrawal rejected" | High risk only adds one extra Audit review step (template upgraded from default to unusual). After review passes, withdrawal proceeds normally |
| "Changing max_amount in auto_settings is all you need" | When adjusting single transaction cap, you must simultaneously evaluate alarm_amount and stop_amount. If single cap is raised but circuit breaker isn't, a few large withdrawals could trigger circuit breaker and close auto-withdrawal |
What to Read Next
| I want to... | Go to |
|---|---|
| Understand how a withdrawal flows end-to-end | Withdrawal Lifecycle |
| See a specific channel's execution details | Channel Execution Manual |
| Push a withdrawal rule change | Withdrawal Change Guide |
| Look up a specific status code number | Withdrawal Data Dictionary |