@solidgrove/claimbee_cli
v0.12.0
Published
CLI for ClaimBee automation billing workflows
Readme
@solidgrove/claimbee_cli
CLI for ClaimBee automation billing workflows.
Install
npm i -g @solidgrove/claimbee_clior run one-off:
npx @solidgrove/claimbee_cli@latest --helpConfigure API URL
export CLAIMBEE_API_URL='https://us-central1-claimed-660e4.cloudfunctions.net/api'Default if unset: https://us-central1-claimed-660e4.cloudfunctions.net/api.
Authenticate
claimbee auth login --api-key <api-key>
claimbee auth logoutBilling
claimbee billing lookup --email [email protected]
claimbee billing lookup --user-id QpJrWP3j9efP7n9IMgcLpI8MFuQ2
claimbee billing refund --payment pi_123
claimbee billing create-link --email [email protected]
claimbee billing cancel --email [email protected]
claimbee billing cancel --email [email protected] --subscription sub_123 --now
claimbee billing void-invoices --subscription sub_123
claimbee billing void-invoices --invoice in_123Looking an account up
--email is case-insensitive. The CLI lowercases it and the backend queries
Stripe with the normalized address, then falls back to a case-insensitive Stripe
search, so [email protected], [email protected], and
[email protected] all resolve the same customer.
An address is searched across all of its aliases. @icloud.com, @me.com and
@mac.com are one Apple mailbox; @gmail.com and @googlemail.com are one
Google mailbox. People pay with whichever alias their device filled in, so
billing lookup --email [email protected] also queries [email protected] and
[email protected], and [email protected] also queries [email protected].
The response's searchedEmails lists every address queried. Never conclude from
one alias that a customer has no subscription — that conclusion is already made
across the whole group.
What is not expanded: a shared local part on another provider
([email protected] vs [email protected]) is a different mailbox that may belong to
a different person, and Gmail's dot-insensitivity does not apply because Stripe
matches the address it stored literally. Both are real sources of misses — they
just need an operator's judgement, not an automatic guess.
cancel and create-link still act on the address you pass. If the lookup found
the subscription under a sibling alias, pass that alias — the entry's
subscription.customerEmail shows which one it is.
--user-id takes the ClaimBee user id — the same value printed as Purchase
ID in every email the app sends. Use it when you do not know which address the
account was created under: it resolves the account first, then looks up billing
across every email and Stripe customer that account owns.
claimbee user lookup --email [email protected]
claimbee user lookup --user-id QpJrWP3j9efP7n9IMgcLpI8MFuQ2Which channel the subscription is on
channels[] says where the money actually is, in the same channel names the
apps read from the backend — web, apple, google. activeChannels keeps
the app's exact meaning (the channels active right now) and is built in the
same order, so you can hold it against what the customer's app is showing them:
if the two disagree, that is the bug, not a quirk.
{
"activeChannels": ["apple"],
"channels": [
{ "channel": "apple", "active": true,
"evidence": ["apphud:claimbee.pro.monthly:regular"],
"accessUntil": "2026-09-20T00:00:00.000Z", "cancelledAt": null,
"test": false, "cancelIn": "app-store-settings" },
{ "channel": "web", "active": false,
"evidence": ["funnelsgrove:resolved-inactive", "stripe:live:sub_1:canceled"],
"accessUntil": "2026-08-24T00:00:00.000Z",
"cancelledAt": "2026-08-24T09:12:00.000Z",
"test": false, "cancelIn": "hosted-portal" }
]
}channels[] also lists a channel that is over, because the people who write to
support are usually exactly those. cancelIn is where that subscription is
actually cancellable: hosted-portal for a funnel subscription, and the
device's own App Store or Play settings for a store purchase — billing cancel
cannot touch a store subscription, and telling a customer otherwise wastes both
your time and theirs.
evidence names the sources that decided the channel. A marker like
funnelsgrove:unavailable means that source was down when the answer was
built: "active": false next to it is Stripe's word alone, not both sources
agreeing.
What an empty result means
Nothing, unless you read sources[]. Every source is reported with what it was
worth this time:
| status | meaning |
|---|---|
| answered | it looked and found something |
| no-match | it looked and there is nothing |
| not-asked | there was no identifier to ask it with |
| failed | it broke; detail says how |
That distinction is the whole point. A customer whose account was deleted after
a refund resolves no email and no Stripe customer id, so Stripe is never
queried at all — that used to print an empty customers array indistinguishable
from "Stripe has no such customer". It now says not-asked. Likewise a
FunnelsGrove miss is no-match with the attempt count, never proof of no
subscription: an email owning several funnel rows resolves to none of them.
unresolvedChannels is the short version — channels whose sources could not be
reached (web, store). If it is non-empty, you do not know yet; say so
instead of guessing.
The lookup no longer depends on the address the customer writes from. It also
resolves them through the Stripe customer ids stored on the account and on the
funnel record (searchedCustomerIds), through FunnelsGrove by funnel id and by
every known email (funnel), and through Apphud by the Firebase uid
(storeSubscriptions). That matters most for a wallet payment: Apple Pay and
Google Pay file the charge under the address the wallet holds, so searching
Stripe by the customer's own address finds nothing and the stored customer id is
the only bridge to it.
A store purchase survives account deletion. Apphud keys its customer by the
Firebase uid, and deleting an account removes our records but nothing of theirs,
so billing lookup --user-id still finds the purchase and reports it — the
user lookup response calls it orphanedStore.
Cancelling
cancel cancels at period end by default, so the customer keeps the access they
already paid for. --now ends access immediately and Stripe refunds nothing —
use it only when a refund is being issued for the same period.
Before an immediate cancel the CLI looks the subscription up and prints what the flag costs:
warning: --now forfeits 27 days of paid access (through 2026-09-01).
Omit --now to cancel at period end.The warning goes to stderr and does not block the command. It is skipped when
there is nothing to lose (a past_due subscription, or a period that already
ended). Pass --subscription when the email has several subscriptions —
otherwise the CLI cannot tell which one it is about to cancel and says so.
Old unpaid invoices
Cancelling a subscription does not close its open invoices in Stripe — they stay
in the retry schedule, and a card the customer later updates triggers an
immediate retry. That is how a subscription cancelled on the 24th takes money on
the 26th, and the payment grants nothing, because access is derived from the
subscription status. So cancel now voids the subscription's unpaid invoices
from the last 60 days and lists them:
{
"voidedInvoices": [
{ "invoiceId": "in_1", "amountDue": 1065, "currency": "usd", "created": "2026-05-28T00:00:00.000Z" }
]
}The 60 days is a lookback window, not a staleness floor — the invoices that keep
collecting are the ones from the run-up to the cancellation, and anything older
than the window is left alone so a cancellation cannot reach back arbitrarily
far. An invoice whose payment is still settling is skipped too. Anything the void
could not close comes back in voidInvoiceErrors — read it, because those
invoices can still collect.
Subscriptions cancelled before this shipped were not swept, so an old "you charged me after I cancelled" complaint can still be genuine.
Voiding invoices on a subscription that is already cancelled
cancel voids on its way through, which does nothing for a subscription that is
already cancelled — Stripe refuses to update or cancel one, so the cancel fails
before it can sweep. That is the exact shape of the complaint support gets ("I
cancelled and it is still trying to charge me"), and closing it used to mean
opening the Stripe dashboard by hand. void-invoices is that operation on its
own:
claimbee billing void-invoices --subscription sub_123
claimbee billing void-invoices --invoice in_123 --dry-runGive it either a subscription (all its open invoices in the window) or one named
invoice (any age — an operator naming an invoice has decided its age is beside
the point). --lookback-days widens the subscription window past the 60-day
default; it does not apply to a named invoice. void-invoice is accepted as an
alias.
Before the irreversible call the CLI runs the same operation as a dry run and prints what it is about to close:
voiding 2 invoices:
in_1 10.65 USD from 2026-08-17
in_2 49.99 USD from 2026-08-01
A voided invoice cannot be undone or reopened in Stripe.--dry-run stops after that preview and prints the payload. When the preview
finds nothing, the command says so and never makes the second call.
It refuses a subscription that still grants access (active, trialing or
past_due and not already set to cancel): that invoice is a debt for access the
customer still has, and cancel is the operation for it — which voids on its way
through anyway. Being scheduled to cancel does not count as still earning, so the
common case (cancelled today, today's invoice still retrying) is allowed. An
invoice whose payment is settling, or one that is not open, is refused too.
Every cancel response reports the same numbers:
{
"accessUntil": "2026-08-05T11:02:31.000Z",
"accessUntilBefore": "2026-09-01T00:00:00.000Z",
"paidDaysForfeited": 27
}Publish
npm run publish:public