Subscription Charged but Purchase Failed: Payment-Entitlement Consistency Design

    This article examines why a subscription can be charged yet still show a "purchase failed" prompt, and how to design payment systems so that every charge has a definite outcome. It traces a real incident to two root causes: a transaction chain without an eventual-consistency fallback, and account security coupled directly into that chain. It then covers defensive design in depth, including order pre-creation with idempotency keys, payment notification validation with dual-channel confirmation, a local message table with reconciliation, and a state machine that converges the flow. It closes with practical guidance for users facing a charged-but-failed situation and a troubleshooting checklist for developers.

    1. Incident Review: From a Single Case to a General Problem

    Let me first lay out the real usage timeline:

    • T0: A normal subscription, used mainly on the web.
    • T1: The user switches to desktop and mobile login. Because the egress network encapsulation (such as proxy forwarding) is incomplete, risk control is triggered and the account is banned, so the user requests a partial refund.
    • T2: The refund is rejected, so the user registers a new account and re-subscribes.
    • T3: The new account’s payment channel completes the charge, but the client shows “unable to complete purchase.” A refund is requested again and quickly approved, with the money returned via the original payment method.

    Once the platform name is stripped away, this is an incident that any subscription service using a “client-triggered payment + server-side asynchronous delivery” model could run into. It exposes at least two design-level gaps:

    1. The transaction chain lacks an eventual-consistency fallback. The charge and the entitlement grant are not bound into a convergent flow, so any failure in between can leave behind an order that is “paid but not delivered.”
    2. Account security is coupled with the transaction chain. Account state directly determines whether payment and delivery can proceed, and users can rarely infer the real cause from a “purchase failed” prompt.

    The rest of the article follows two threads: how this kind of failure arises technically and how to defend against it, and how users and developers should each diagnose and handle it when it occurs.

    2. Root Cause Analysis: The Money Was Charged, So Why Didn’t the Entitlement Arrive?

    2.1 Client-driven delivery is inherently unreliable

    Subscription apps on mobile mostly use the in-app purchase channel provided by the app store. The typical flow is: the client initiates the purchase → the payment channel completes the charge → the client obtains the payment credential and reports it to the server → the server validates the credential and grants the entitlement. The “purchase failed” prompt usually appears at that last step. There are four common breakpoints:

    • The client process is reclaimed. The payment is launched from an external channel, and by the time it completes the system may have already moved the app to the background. On a weak network, or if the process is killed, the credential-reporting request is never sent or its response is never received.
    • The credential is lost. On older systems, purchase credentials depend on local client storage. Clearing the cache or reinstalling the app makes them disappear, so after re-login the server cannot prove the user has paid.
    • The callback is lost or delayed. The server also receives asynchronous notifications from the payment channel, but those notifications may arrive out of order, be duplicated, delayed, or lost. If delivery depends solely on the notification, any single loss leaves an undelivered order.
    • Duplicate delivery or duplicate charge. When the notification and the client report arrive at the same time, a handler without idempotency protection may open multiple subscription periods for the user, or produce the anomaly of “one charge, multiple deliveries.”

    Client reporting is only a fast path, not the sole path. Any message whose delivery depends on the client must be assumed to be losable.

    2.2 The “dual-write” problem in distributed transactions

    From a database perspective, two independent systems are involved here: the payment channel’s fund ledger and the business system’s order ledger. “Charge succeeded” and “entitlement granted” are two separate writes and cannot be placed in a single database transaction. The two common classes of solutions each carry a cost:

    • Strong-consistency schemes such as two-phase commit. The prepare phase freezes the funds, and everything commits together once all resources are ready, but the implementation is complex and the blocking time is long.
    • Message-based eventual-consistency schemes. Persist first, then dispatch; failed messages are retried automatically and backed by reconciliation. This is simple to implement and offers high throughput, but there is a brief window of inconsistency.

    Internet businesses generally choose the latter, on the premise that three things hold: atomicity between the local transaction and message dispatch, consumer idempotency, and the ability to compensate for failures. None of the three can be missing.

    Another easily overlooked problem is the timing race between notification and polling. For example: a polling task scans an order that has timed out without confirmation and initiates a channel refund, but by the time the refund request reaches the channel, a delayed purchase notification has just completed the charge. The result may be that “the refund has already been submitted, and the charge happens only afterward.”

    2.3 Account risk state cuts off the delivery chain

    In the original experience, the “unable to purchase” prompt looks more like the result of an account security policy than a payment failure: the charge had already completed on the channel side, and the business side performed an account check before delivery, found the account banned, and aborted the grant. Each individual step follows sound security logic, but together they become “charge succeeded, purchase failed.”

    This is mechanistically similar to the avalanche caused by heartbeat storms (reconnection storms) in the industry. During a failure, clients retry in a concentrated burst, the retry traffic further overwhelms dependent services, the dependency failures trigger still more retries, and the situation eventually escalates into global unavailability. To break the loop, the key is not to “retry harder” but to limit the amplification effect of retries:

    • Exponential backoff and jitter;
    • Request caps;
    • Circuit-breaker isolation.

    Mapped to the transaction scenario, this means applying rate-limited compensation to “already-charged” orders rather than letting them linger indefinitely or retry blindly.

    3. Defensive Design: Give Every Charge a Definite Destination

    3.1 Order pre-creation and idempotency keys

    When the user initiates a purchase, the server first creates an order in a “pending payment” state and binds it to the user, the SKU, and an idempotency key (either the channel transaction ID, or a unique request ID generated by the business side and passed through to the channel). Every subsequent payment notification, credential report, and refund request locates the order by that idempotency key, and a unique index rules out duplicate insertion at the database level:

    1. CREATE TABLE subscription_order (
    2. order_id BIGINT PRIMARY KEY,
    3. user_id BIGINT NOT NULL,
    4. sku_code VARCHAR(64) NOT NULL,
    5. provider_txn VARCHAR(128) NOT NULL, -- Provider transaction ID / idempotency key
    6. status TINYINT NOT NULL, -- Status values described in 3.4
    7. period_end DATETIME NULL, -- Entitlement expiration time
    8. created_at DATETIME NOT NULL,
    9. updated_at DATETIME NOT NULL,
    10. UNIQUE KEY uk_provider_txn (provider_txn)
    11. );

    Every processing entry point follows the pattern of “attempt to insert a processing record first; continue only if the insert succeeds”:

    • Only the request whose insert succeeds is responsible for the actual delivery or refund;
    • Duplicate requests return the first result directly;
    • Persist first, then process, so that under concurrency two requests are not both judged to be “first.”

    Illustrative code:

    1. def deliver(provider_txn: str) -> dict:
    2. # 1) Claim processing rights using the provider transaction ID as the idempotency key
    3. if not claim_once(table="payment_event", key=provider_txn):
    4. return {"status": "DUPLICATED"} # Already processed, return directly
    5. # 2) Query and lock the order in the order database
    6. order = orders.select_for_update(provider_txn)
    7. if order is None:
    8. return {"status": "ORDER_NOT_FOUND"} # Long payment; escalate to manual or auto-hold
    9. if order.status != OrderStatus.PAID_UNDELIVERED:
    10. return {"status": "ALREADY_SETTLED"}
    11. # 3) Grant the entitlement and advance the status (in the same transaction as the status update)
    12. entitlements.grant(order.user_id, order.sku_code, period=order.period)
    13. orders.update(order.order_id, status=OrderStatus.DELIVERED)
    14. return {"status": "OK"}

    You also need to agree on ownership of the idempotency key: it is most reliable to have the business side generate the request ID and pass it through to the channel. When only a channel-generated transaction ID is available, you must guarantee that the same payment carries the same value across different notification channels. Otherwise the same idempotency key maps to different orders, and idempotency becomes meaningless.

    3.2 Payment notification validation and dual-channel confirmation

    When the server processes a payment notification, it must complete four validations in order:

    1. Verify the notification signature;
    2. Check the amount, currency, and product;
    3. Verify that the user and order in the notification match ownership;
    4. Validate the transaction status.

    Any failure should reject processing and record an alert. You should also make the server notification and the client report back each other up: the client report can deliver instantly after the user pays to improve the experience, while the server notification serves as the final fallback, so that whichever arrives first can complete delivery.

    3.3 Local message table and reconciliation compensation

    The local message table is the most common implementation of “eventual consistency.” The business operation and the message record are written in the same local transaction. A background dispatcher scans for unsent messages and retries with exponential backoff; the consumer deduplicates by idempotency key to ensure duplicate delivery does not cause duplicate fulfillment:

    Order table change Message written in the same transaction Dispatcher action Consumer result
    Insert a pending-payment order Order created Send subscription-check message Verify account and network environment; if not satisfied, notify the user with a specific reason
    Payment succeeds, pending delivery Pending-delivery event Retry with backoff on failure Idempotently grant the entitlement; set the order to opened
    Refund initiated, pending recovery Refund-request event Retry with backoff on failure Idempotently deduct the entitlement; set the order to refunded
    Timeout without payment Order-close event Retry with backoff on failure Idempotently close the order; release inventory or an offer quota

    With common cloud capabilities such as message queues, logging services, and monitoring alerts, this kind of flow can be implemented without building your own message middleware or alerting channels. The dispatcher should also set a retry limit; messages that exceed it go to a dead-letter queue and trigger an alert for manual intervention, avoiding endless ineffective retries that keep consuming resources.

    What truly lets users “afford to wait” is reconciliation. Payment channels generally provide the ability to query transaction details by day or by time range, which you can use to pull statements on a schedule and compare them line by line against local orders:

    Reconciliation result Condition Action
    Long payment Channel charged, no local order Rebuild the order by transaction ID and deliver; if ownership cannot be confirmed, move to hold for manual review
    Short payment Local shows paid, no successful channel record Verify the notification source and its authenticity; if abnormal, revoke delivery and investigate
    Status mismatch Local pending delivery, channel paid successfully Trigger re-delivery
    Status mismatch Local paid, channel refunded Recover the entitlement and sync the order status
    Duplicate delivery Multiple delivery records for the same transaction ID Keep the earliest one and reverse the rest

    3.4 Converging the flow with a state machine

    The purpose of a state machine is to make explicit “what state an order is in at any given moment and where it is allowed to transition,” avoiding the illegal states produced by scattered if-else branches:

    1. INIT --user initiates purchase--> PENDING --payment success--> PAID_UNDELIVERED
    2. | \
    3. delivery success| \provider confirms no charge
    4. v v
    5. DELIVERED CLOSED
    6. PAID_UNDELIVERED --timeout without payment--> CLOSED
    7. INIT / PENDING --notification or order supplement finds a charge--> PAID_UNDELIVERED (status reconciled)
    8. DELIVERED --refund success--> REFUNDING --entitlement recovery complete--> REFUNDED

    Every state transition should follow three principles:

    • Use conditional updates: UPDATE ... WHERE status = expected_value, judging success by the number of affected rows;
    • Record a state-change log: save the before and after states, the trigger source, and the operator;
    • Set a processing deadline: a state that times out without completing must be caught by a scanning task.

    Only when the lifecycle of the “paid but undelivered” state is explicitly bounded (for example, auto-re-deliver or escalate to manual review if it remains undelivered past an agreed deadline) does “charge succeeded but purchase failed” go from an occasional complaint to a convergent, measurable engineering metric.

    4. Account Security and Network-Layer Factors: Turning the “Black Box” into Something Explainable

    4.1 Common causes of triggering risk control

    Frequent egress IP changes, datacenter or proxy address ranges flagged as high-risk, simultaneous logins from multiple devices, and a mismatch between the account and its usual region can all trigger risk control. What some users describe as “incomplete IP encapsulation” usually refers to an incomplete proxy-chain configuration, where DNS resolution or part of the traffic does not exit through the proxy, showing up as IP leakage or a geolocation that does not match the declared one. This kind of problem cannot be solved by repeated retries and should be handled from two directions: network configuration and account appeal.

    4.2 Replacing “one-size-fits-all” with layered control

    Embedding security controls entirely into the main transaction path tends to “hit paying users by mistake”: the user has already paid, yet is blocked at the door by risk control. A more robust approach is layered control, applying different measures by risk level:

    Risk level Detection signals (examples) Handling
    Low Common devices, common regions, regular behavior Allow normally
    Medium New device, sudden IP-geolocation change, high-frequency short-term operations Secondary verification or manual review
    High Proxy/datacenter address ranges, mismatch with the historical payment account, abnormal associated accounts Restrict login or features, freeze the disputed transaction, proactively contact the user to verify

    Three points are essential to layering:

    • Preserve the fulfillment commitment after a successful payment. An order that has been charged successfully must be delivered, or refunded proactively when delivery is impossible; the cost of risk should not be shifted onto the user.
    • Give a comprehensible reason and an appeal entry when blocking. A vague prompt like “unable to complete purchase” directly drives up support load and repeat purchases.
    • Layer the refund policy. Distinguish auto-approval, manual review, and rejection by refund reason, and define clear deadlines and criteria, so that users do not turn to “retrying with a new account” after a single rejection, which then spawns duplicate-charge risk.

    Note also that risk-control services such as IP reputation and device fingerprinting are mostly hosted overseas, and their availability is heavily affected by international network conditions. When the risk-scoring service times out, the transaction chain should have an explicit degradation policy, such as defaulting to allowing low-risk orders on timeout and moving them to asynchronous review, rather than waiting indefinitely or rejecting across the board.

    4.3 Dealing with jitter through smooth reconnection and asynchronization

    The most common failure mode during network jitter is the retry storm:

    1. The client fails to reconnect repeatedly;
    2. Requests keep piling up;
    3. Gateway and backend resources are exhausted;
    4. Functions that were originally working fail as well.

    The corresponding engineering measures include:

    • Client: exponential backoff with random jitter, a maximum retry count and request cap, and a full state refresh once the network recovers;
    • Gateway layer: rate limiting, circuit breaking, and queuing per user or IP;
    • Critical operations: process payment confirmation, entitlement queries, and the like asynchronously through a message queue.

    Availability should also not be assessed at a single point. Even if the payment channel, the risk-control service, and the message queue each reach 99.9% availability, their theoretical availability when chained in series is only about 99.7%, with roughly three times the downtime of a single point. Redundancy, degradation, and compensation are therefore not “optional.”

    5. Users: What to Do When You’re “Charged but Purchase Failed”?

    Prioritize self-service recovery, then consider a refund, and unbind the payment method before requesting a refund:

    1. Confirm the nature of the charge. Check the purchase record in your payment channel to confirm whether it is an actual charge or an “authorization hold.” Some channels make a small authorization first when verifying a payment method; once the hold is released, the funds are returned, which does not mean you have been charged.
    2. Try self-service recovery. Fully quit the app and log in again; look inside the app for entries such as “Restore Purchases” or “Sync purchase history”; switch to a stable network and retry. These operations may trigger the client to re-sync existing credentials and thus deliver the missing entitlement.
    3. Check account consistency. Confirm that the currently logged-in account matches the paying account, especially after registering a new account, since the old account’s entitlements do not transfer automatically.
    4. Keep evidence. The order number or transaction ID, the charge time, the amount, screenshots of the payment credential and the error prompt, and timestamps of support tickets are all key materials for a refund and an appeal.
    5. Unbind the payment method before requesting a refund. In the original experience, one common reason a new account was “charged and then told the purchase failed” is that the payment method was still bound to the old, banned account. When the new account initiated the purchase it reused that payment method, the channel charged normally, and after the business side validated the account state it refused delivery. When you request a refund, also handle the binding on the original account to avoid the next charge repeating.
    6. Watch the refund timing. Refunds are usually returned via the original payment method, and the arrival time depends on the payment channel’s rules. It may take several business days, and you can track the status in the channel’s statement.
    7. Know the limits of network access. When using proxies or acceleration tools to reach the service, pay attention to the terms of service and local laws and regulations, and choose a stable, compliant network environment. When appealing an account, state the situation truthfully and keep complete transaction records.

    6. Developers: Troubleshooting and Retrospective Checklist

    When a user reports “already charged, not delivered,” diagnose in the following order:

    1. Trace the full chain keyed by transaction ID.串联 logs across the payment notification, order table, delivery records, and entitlement table by the same transaction ID, and pinpoint where in the chain the break occurred.
    2. Compare the two channels. Compare the arrival times of the payment notification and the client report to determine whether there was notification loss, delay, or duplication.
    3. Check the account state change timeline. Confirm the ordering of the ban action and the payment time to distinguish “payment failure” from “delivery blocked.”
    4. Check idempotency and concurrency. Confirm whether the unique key on the processing-record table is in effect and whether the same transaction ID was processed concurrently.
    5. Check the reconciliation result. Determine whether it is a long payment, a short payment, or a status mismatch, and correct it per the established handling flow.
    6. Review metrics and improvements. Tally the count and proportion of such orders, fill in monitoring and alerting gaps, and assess whether the risk-control check should be converged to a stage before payment.

    You can use a logging service to search the full-chain logs in one place, and a visualization dashboard to track metrics such as “number of paid-but-undelivered orders,” “payment success rate,” and “refund processing time.” When a metric stays beyond its threshold for a sustained period, monitoring alerts notify the on-call engineer automatically.

    7. Conclusion

    “Charged successfully yet told the purchase failed” is not a simple payment failure but the joint result of distributed consistency, idempotency design, account risk control, and network quality. For developers, the core principle is a single one: give every charge a predetermined destination. Either deliver successfully, or refund automatically; no amount of money may linger indefinitely in the ambiguous “charged but not delivered” state.

    When the “paid but undelivered orders” count can be monitored in real time, compensated automatically on timeout, and reconciled line by line, this kind of problem turns from an inexplicable complaint into a quantifiable, convergent engineering metric. For users, the lowest-cost path is to try self-service recovery first, then request a refund per policy, and keep complete evidence.