Repair Subscription lifecycle
Card check read as a failed payment
A zero amount Stripe card check went through the same failure path as a real renewal charge. The subscription got dunned, the customer got a "your payment failed" email, and support had to explain that nothing was ever actually charged. This guide covers why a $0 check gets mistaken for a failed payment, and a small script that finds subscriptions punished this way and puts them back where they belong.
Stripe sometimes creates a PaymentIntent with an amount of zero, just to check a card is still valid, not to collect a renewal. If that check comes back as anything other than succeeded, some dunning logic treats it exactly like a failed real charge and puts the subscription on hold or cancels it. Run a small Python or Node.js checker that reads the PaymentIntent behind each recently failed renewal, and if the amount is zero, it clears the false failure and restores the subscription. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce Subscriptions and Stripe sometimes need to confirm a saved card still works without charging the customer anything. This happens when a card is refreshed after a bank re-issues it, when a free trial signs up with a card on file, or when a subscription is paused and resumed. Stripe runs this as a PaymentIntent with amount set to 0.
A zero amount PaymentIntent can still land in a status that is not succeeded, for example requires_payment_method if the card check itself could not be completed. The renewal failure handler was written to react to "the PaymentIntent behind this order or subscription did not succeed," without first asking whether any money was actually requested. So a $0 check with a rough status looks identical to a real declined renewal, and the same dunning emails, retry schedule, and eventual cancellation kick in for a payment that was never attempted.
Why it happens
This slips through because most failure handling code is written around the assumption that any PaymentIntent tied to a subscription represents an attempt to collect real money. A few specific paths create that gap:
- Stripe SetupIntents and zero amount PaymentIntents are both valid ways to verify a card, and either can be created outside the normal renewal cycle, for example by a card updater webhook.
- A generic
payment_intent.payment_failedwebhook handler reacts to the event type alone and calls the subscription's failure hook without first checkingamountoramount_receivedon the intent. - The order or subscription note only says "payment failed," with no mention that the linked intent was for $0, so a shop manager reviewing it by eye has no reason to doubt it.
- Retry and dunning schedules run automatically once a failure is recorded, so the mistake compounds over days before anyone notices the subscription was punished for a check, not a charge.
This is a known category of confusion in the wider Stripe and WooCommerce Subscriptions community: automated card verification is meant to be invisible to the customer, and any code path that surfaces it as a payment failure is treating a plumbing detail as a business event.
A PaymentIntent's amount tells you what was actually being asked for. If the amount is 0, nothing was charged, so nothing can have "failed to be charged." A checker that reads the PaymentIntent behind every recent renewal failure, and only trusts the failure when real money was on the line, catches this class of mistake without touching genuine declines.
The fix, as a flow
We do not change how card checks are created, since WooCommerce Subscriptions and Stripe both need them. We add a job that looks at subscriptions that were recently marked as having a failed renewal, reads the PaymentIntent id saved on the related order, and asks Stripe what that intent's amount actually was. When the amount is zero, the failure was a card check, not a renewal, and the script clears the dunning state and restores the subscription. When the amount matches the subscription total, it leaves the failure alone.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="3"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="3"
export DRY_RUN="true" // start safe, change to false to write
List subscriptions with a recent failed renewal
Ask the WooCommerce REST API for subscriptions that are On-Hold or Pending Cancellation with a last order created inside the lookback window. We only look at recent failures, since a real decline from months ago should not suddenly get reactivated by an old check.
import os, datetime, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "3"))
def held_subscriptions():
after = (datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)).isoformat() + "T00:00:00"
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "on-hold,pending-cancel", "modified_after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
yield from batch
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 3);
async function* heldSubscriptions() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/subscriptions?status=on-hold,pending-cancel&modified_after=${after}&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
if (!res.ok) throw new Error(`Woo subscriptions returned ${res.status}`);
const batch = await res.json();
if (!batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
Read the PaymentIntent id from the last order
The PaymentIntent id that triggered the failure lives on the subscription's last related order, saved as order meta _stripe_intent_id, or as the order's transaction_id when that meta key is missing. Load the order through the WooCommerce REST API, then pull the id from whichever field has it.
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
async function getOrder(orderId) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3/orders/${orderId}`, { headers: { Authorization: AUTH } });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo order returned ${res.status}`);
return res.json();
}
Decide, with one pure function
Keep the decision in its own function that takes the subscription's last order and the Stripe PaymentIntent, and returns an action. This stays pure and easy to test. The rule: if there is no intent to check, leave it alone. If the intent's amount was zero, no money was ever requested, so this was a card check, and we can clear the false failure. If the intent amount matches the subscription total, this was a real attempt, so we leave the failure as it is.
FAILED_STATUSES = {"on-hold", "pending-cancel"}
def decide(subscription, intent):
if subscription["status"] not in FAILED_STATUSES:
return ("skip", "subscription is not in a dunned state")
if intent is None:
return ("skip", "no Stripe intent to check")
if intent.get("amount", 0) == 0:
return ("restore", "the failed intent was a zero amount card check")
if intent.get("status") == "succeeded":
return ("skip", "the intent actually succeeded, nothing to fix")
return ("skip", "a real charge was attempted and declined")
const FAILED_STATUSES = new Set(["on-hold", "pending-cancel"]);
export function decide(subscription, intent) {
if (!FAILED_STATUSES.has(subscription.status)) return ["skip", "subscription is not in a dunned state"];
if (!intent) return ["skip", "no Stripe intent to check"];
if ((intent.amount || 0) === 0) return ["restore", "the failed intent was a zero amount card check"];
if (intent.status === "succeeded") return ["skip", "the intent actually succeeded, nothing to fix"];
return ["skip", "a real charge was attempted and declined"];
}
Clear the false failure and reactivate
When the action is restore, set the subscription back to Active and add a note explaining exactly why, so a shop manager reviewing it later understands this was not a manual override. Do the same for the related renewal order if it was left on Failed, since that order should not count as a lost sale either.
def restore(subscription_id, intent):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "active"}, auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reactivated by the card check auditor. PaymentIntent {intent['id']} "
f"had amount 0, a card check, not a failed renewal. No dunning is owed here."},
auth=AUTH, timeout=30,
).raise_for_status()
async function restore(subscriptionId, intent) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "active" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reactivated by the card check auditor. PaymentIntent ${intent.id} ` +
`had amount 0, a card check, not a failed renewal. No dunning is owed here.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would restore. Read the output, trust it, then switch it off to let it write. Run it every few hours with cron, since card checks are not as time sensitive as a real payment issue.
Always start with DRY_RUN=true. This script reactivates subscriptions, so you want to see its exact plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete checker in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only ever restores a subscription when the linked Stripe intent proves no money was actually requested.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Fix subscriptions dunned for a zero amount Stripe card check, not a real failed renewal.
Stripe sometimes verifies a saved card with a $0 PaymentIntent, for example after a card
updater event or a trial signup. If that check does not come back clean, some failure
handling treats it exactly like a declined renewal charge. This walks recently dunned
subscriptions, reads the PaymentIntent behind the last order, and reactivates any
subscription whose "failure" was really a zero amount check. Safe to run again and again.
"""
import os
import datetime
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("card_check_auditor")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "3"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FAILED_STATUSES = {"on-hold", "pending-cancel"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def get_order(order_id):
if not order_id:
return None
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def decide(subscription, intent):
if subscription["status"] not in FAILED_STATUSES:
return ("skip", "subscription is not in a dunned state")
if intent is None:
return ("skip", "no Stripe intent to check")
if intent.get("amount", 0) == 0:
return ("restore", "the failed intent was a zero amount card check")
if intent.get("status") == "succeeded":
return ("skip", "the intent actually succeeded, nothing to fix")
return ("skip", "a real charge was attempted and declined")
def held_subscriptions():
after = (datetime.date.today() - datetime.timedelta(days=LOOKBACK_DAYS)).isoformat() + "T00:00:00"
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/subscriptions",
params={"status": "on-hold,pending-cancel", "modified_after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
yield from batch
page += 1
def restore(subscription_id, intent):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
json={"status": "active"}, auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
json={"note": f"Reactivated by the card check auditor. PaymentIntent {intent['id']} "
f"had amount 0, a card check, not a failed renewal. No dunning is owed here."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
restored = 0
for subscription in held_subscriptions():
last_order_id = subscription.get("last_order_id") or subscription.get("last_order")
order = get_order(last_order_id)
intent = get_intent(intent_id_of(order)) if order else None
action, reason = decide(subscription, intent)
if action != "restore":
continue
log.info("Subscription %s: %s. %s", subscription["id"], reason, "would restore" if DRY_RUN else "restoring")
if not DRY_RUN:
restore(subscription["id"], intent)
restored += 1
log.info("Done. %d subscription(s) %s.", restored, "to restore" if DRY_RUN else "restored")
if __name__ == "__main__":
run()
/**
* Fix subscriptions dunned for a zero amount Stripe card check, not a real failed renewal.
*
* Stripe sometimes verifies a saved card with a $0 PaymentIntent, for example after a
* card updater event or a trial signup. If that check does not come back clean, some
* failure handling treats it exactly like a declined renewal charge. This walks recently
* dunned subscriptions, reads the PaymentIntent behind the last order, and reactivates
* any subscription whose "failure" was really a zero amount check. Safe to run again
* and again.
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 3);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FAILED_STATUSES = new Set(["on-hold", "pending-cancel"]);
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function decide(subscription, intent) {
if (!FAILED_STATUSES.has(subscription.status)) return ["skip", "subscription is not in a dunned state"];
if (!intent) return ["skip", "no Stripe intent to check"];
if ((intent.amount || 0) === 0) return ["restore", "the failed intent was a zero amount card check"];
if (intent.status === "succeeded") return ["skip", "the intent actually succeeded, nothing to fix"];
return ["skip", "a real charge was attempted and declined"];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function getOrder(orderId) {
if (!orderId) return null;
return woo(`/orders/${orderId}`);
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function* heldSubscriptions() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/subscriptions?status=on-hold,pending-cancel&modified_after=${after}&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const sub of batch) yield sub;
page++;
}
}
async function restore(subscriptionId, intent) {
await woo(`/subscriptions/${subscriptionId}`, {
method: "PUT",
body: JSON.stringify({ status: "active" }),
});
await woo(`/subscriptions/${subscriptionId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Reactivated by the card check auditor. PaymentIntent ${intent.id} ` +
`had amount 0, a card check, not a failed renewal. No dunning is owed here.`,
}),
});
}
export async function run() {
let restored = 0;
for await (const subscription of heldSubscriptions()) {
const orderId = subscription.last_order_id || subscription.last_order;
const order = await getOrder(orderId);
const intent = order ? await getIntent(intentIdOf(order)) : null;
const [action, reason] = decide(subscription, intent);
if (action !== "restore") continue;
console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would restore" : "restoring"}`);
if (!DRY_RUN) await restore(subscription.id, intent);
restored++;
}
console.log(`Done. ${restored} subscription(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a subscription gets reactivated. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.
from card_check_auditor import decide, intent_id_of
def intent(**over):
base = {"id": "pi_1", "amount": 0, "status": "requires_payment_method"}
base.update(over)
return base
def test_restore_when_intent_amount_is_zero():
sub = {"status": "on-hold"}
assert decide(sub, intent())[0] == "restore"
def test_skip_when_subscription_not_dunned():
sub = {"status": "active"}
assert decide(sub, intent())[0] == "skip"
def test_skip_when_no_intent_to_check():
sub = {"status": "on-hold"}
assert decide(sub, None)[0] == "skip"
def test_skip_when_real_charge_declined():
sub = {"status": "on-hold"}
assert decide(sub, intent(amount=2900, status="requires_payment_method"))[0] == "skip"
def test_skip_when_intent_actually_succeeded():
sub = {"status": "pending-cancel"}
assert decide(sub, intent(amount=2900, status="succeeded"))[0] == "skip"
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_123"
def test_intent_id_falls_back_to_transaction_id():
order = {"meta_data": [], "transaction_id": "pi_456"}
assert intent_id_of(order) == "pi_456"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./card-check-auditor.js";
const intent = (over = {}) => ({ id: "pi_1", amount: 0, status: "requires_payment_method", ...over });
test("restore when intent amount is zero", () => {
assert.equal(decide({ status: "on-hold" }, intent())[0], "restore");
});
test("skip when subscription not dunned", () => {
assert.equal(decide({ status: "active" }, intent())[0], "skip");
});
test("skip when no intent to check", () => {
assert.equal(decide({ status: "on-hold" }, null)[0], "skip");
});
test("skip when real charge declined", () => {
assert.equal(decide({ status: "on-hold" }, intent({ amount: 2900, status: "requires_payment_method" }))[0], "skip");
});
test("skip when intent actually succeeded", () => {
assert.equal(decide({ status: "pending-cancel" }, intent({ amount: 2900, status: "succeeded" }))[0], "skip");
});
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
Case studies
The reissued card that dunned a whole cohort
A bank reissued cards for a large batch of customers on the same week. Stripe's card updater refreshed each saved card with a $0 verification, and a handful of those checks came back rough because a few of the reissued cards were not yet active. The store's failure handler read each one as a declined renewal and put around sixty subscriptions on hold overnight.
The auditor found every one of them in a single dry run, confirmed each linked intent was for $0, and reactivated all sixty without a single real renewal being touched.
The trial signup that looked like a failed sale
A store offered a free trial that still asked for a card up front, verified with a $0 PaymentIntent. A batch of trial signups hit a card check that briefly came back as requires_payment_method before the customer's bank approved it a minute later. The subscriptions were flagged as failed before that approval landed, and dunning emails went out to brand new trial customers who had never been charged a cent.
Running the checker on a four hour schedule caught this fast enough that most customers never even noticed the false email had gone out.
After this runs on a schedule, a $0 card check can no longer masquerade as a failed sale. Genuine declines still trigger dunning exactly as they should, since the checker only acts when the linked Stripe intent proves zero money was ever requested. Keep it running even after any root cause is patched, since card checks with a rough status will keep happening from time to time.
FAQ
Why did my customer get a failed payment email when their card was never charged?
Stripe sometimes runs a zero amount card check to confirm a card is still valid, for example when a saved card is refreshed or a trial subscription is set up. If the store's failure handling does not check the amount, that zero amount check can be read as a failed renewal and trigger dunning even though no real charge was ever attempted.
Is it safe to auto repair subscriptions flagged this way?
Yes, when the script confirms the linked Stripe PaymentIntent has an amount of zero and the subscription is not actually past its renewal date for a real charge. Start in dry run mode to review the exact list before it writes anything back to a subscription.
How do I tell a real failed renewal from a card check in Stripe?
Look at the PaymentIntent amount. A real renewal charge carries the subscription total in minor units, such as 2900 for 29.00. A card check is created with amount 0. If the amount is zero, no money was ever requested, so it cannot be a failed renewal.
Related field notes
Citations
On the problem:
- Stripe docs: setup and verification of a card without charging it, including zero amount PaymentIntents. docs.stripe.com/payments/setup-intents
- WooCommerce Subscriptions docs: how automatic renewal failures trigger the dunning and retry schedule. woocommerce.com/document/subscriptions/renewal-process
- Stripe docs: card updater events and how issuers push new card details without a real charge. docs.stripe.com/co-badged-cards-compliance/card-updater
On the solution:
- Stripe API: retrieve a PaymentIntent and read its amount and status fields. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce Subscriptions REST API: read and update a subscription's status. woocommerce.github.io/subscriptions-rest-api-docs
- WooCommerce REST API: add an order or subscription note for an audit trail. woocommerce.github.io/woocommerce-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your false dunning?
If this saved a customer from a confusing email or saved you a pile of "why was I charged" tickets, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi