> "find legal things and other methods and sections and parts and Code to be able to make this
> working safe for both me and them and also let it work Great for both. at first they do the job
> they get paid at the end. it also should show them how much that will pay out and how long it
> will take and more data"
I am not a lawyer or an accountant and none of this is legal, tax or financial advice. Every
citation below is to a primary source or a firm's published analysis, dated, so you can hand this
to someone who *is* one and get an answer quickly instead of paying them to start from zero. The
sections marked COUNSEL are the ones to take to them first.
---
Do not hold the money yourself. The trigger for money-transmitter licensing is custody: the
moment your platform receives a buyer's money and later pays it to a worker, you are holding funds
that belong to someone else, and that activates licensing requirements in most US states.
Migrating architecture later, at volume, creates retroactive exposure *and* forces you to
re-onboard every single worker — which is why this is a before-you-launch question.
Four known structures, and where Panda Productions lands:
Risk factors that raise exposure even inside the facilitator model, and each maps to a rule later
in this document: holding worker balances or wallets (don't — §3.4), long payout delays
(the escrow window is the thing to minimise — §3.3), payout-detail changes (the single
highest-risk event, usually account takeover — §5.2), and absorbing chargebacks when a worker
has no balance to cover them (§4.3).
---
Stripe Connect offers three charge types. For this board there is only one candidate.
Stripe's own words: separate charges and transfers exist "to transfer funds from one payment to
multiple connected accounts, or when a specific user isn't known at the time of the payment."
That is the Contracts flow and the Adventure pool, described exactly.
With this charge type you are the merchant of record, and "your account balance gets debited
for the cost of the Stripe fees, refunds, and chargebacks." That is a real liability and it must
be priced into the platform fee, not discovered later.
transfer_group — a string tying a charge and its transfers together. Use the contract id.Stripe is explicit that it "only identifies associated objects. It doesn't affect any standard
functionality." It is for your reconciliation, not for control.
source_transaction — this one *is* control. By default a transfer fails if it exceeds your available balance, and Stripe does not retry failed transfers. Setting source_transaction
to the originating charge makes the transfer request always succeed and simply wait until those
specific funds have settled. Use it on every transfer. Without it, a payout can fail
silently because an unrelated automatic payout drained the balance an hour earlier.
Stripe also warns that automatic payouts on the platform account can interfere with transfers that
lack a source_transaction — so set the platform's payout schedule deliberately.
supports 46+ countries; you own the theming. API onboarding is explicitly not recommended by
Stripe ("resource-intensive… requires regular updates").
that dashboard type is immutable: changing it later means creating a new Account object, so
decide once.
and pursues negative balances. Taking that on yourself requires "both the operational team and
the engineering resources" — which is a second business, not a feature.
COUNSEL: even inside this model, holding funds on the platform balance between charge and
transfer is precisely the custody window §1 is about. Stripe has a private-preview "funds
segregation" feature that "keeps payment funds in a protected holding state before you transfer
them" for this reason — worth asking your account manager about, and worth raising with counsel
alongside the agent-of-payee question.
---
publish accept submit poster accepts settle
│ │ │ (or auto-accept) │
▼ ▼ ▼ ▼ ▼
CHARGE the contract work is review window TRANSFER to
poster assigned delivered closes worker(s)
─────────────────────── funds on platform balance ─────────── ──────────►
← minimise this window → payout to bank
A worker must never finish a job and *then* discover the money was never there. The contract does
not go OPEN until the charge has succeeded. The board shows an explicit Funded state, and an
unfunded contract is not visible to workers at all.
Use an immediate capture, not a manual-capture authorisation hold: card authorisations expire in
about a week, and Jobs and Missions run longer than that.
Every day funds sit on your balance is a day of custody. So:
able to hold a worker's money hostage;
and refunds.
There is no Panda Productions balance. Money is with the processor, or it is with a person. A
platform-held balance is the exact thing §1 says not to build, and it converts a facilitator model
back into a custody model with no other change.
---
Sketches, not final. Naming matches §2.3 of the goal doc.
// POST [internal endpoint] — owner/poster only
// The contract does not become OPEN until this charge has SUCCEEDED.
const intent = await stripe.paymentIntents.create({
amount: c.rewardCents + platformFeeCents(c), // poster pays reward + fee
currency: 'usd',
customer: poster.stripeCustomerId,
payment_method: poster.defaultPaymentMethodId,
off_session: true,
confirm: true,
transfer_group: `contract_${c.id}`, // ties charge -> later transfers
metadata: { contractId: c.id, kind: c.kind, posterId: poster.id },
}, {
// The contract id IS the idempotency key: a retried publish can never
// charge twice, however the client behaved.
idempotencyKey: `publish_${c.id}`,
});
// Do NOT flip to OPEN here. The webhook does that (§4.4) — the client can die
// between the API call and the response, and the truth is Stripe's, not ours.
await contracts.patch(c.id, { escrow: { intentId: intent.id, state: 'pending' } });
// Called only from the ACCEPTED_WORK transition, server-side, never from a route
// the client can reach directly.
async function releaseEscrow(assignment) {
const c = await contracts.get(assignment.contractId);
const charge = c.escrow.chargeId; // from the webhook
const splits = payoutSplits(c, assignment); // [{userId, cents}]
const total = splits.reduce((n, s) => n + s.cents, 0);
if (total > c.rewardCents) throw new Error('split exceeds escrowed reward');
const results = [];
for (const s of splits) {
const worker = await panda.profile(s.userId);
if (!worker.stripeAccountId || !worker.payoutsEnabled) {
// Not a failure of the work — a failure of onboarding. Park it, tell them,
// and retry when the account turns on. Never silently drop a payout.
await payouts.park(assignment.id, s.userId, s.cents, 'payouts_not_enabled');
continue;
}
const t = await stripe.transfers.create({
amount: s.cents,
currency: 'usd',
destination: worker.stripeAccountId,
transfer_group: `contract_${c.id}`,
// THE IMPORTANT ONE. Without it a transfer fails outright when the platform
// balance is short, and Stripe does not retry.
source_transaction: charge,
metadata: { assignmentId: assignment.id, userId: s.userId, kind: c.kind },
}, {
// Per assignment AND per user, so an Adventure's five transfers are five
// distinct idempotent operations rather than one that can half-apply.
idempotencyKey: `payout_${assignment.id}_${s.userId}`,
});
results.push({ userId: s.userId, transferId: t.id, cents: s.cents });
}
await ledger.append(c.id, 'PAID', { splits: results, at: Date.now() });
return results;
}
// Adventures: the pool split. The formula is frozen at publish and stored ON the
// contract, so what settles is what people agreed to when they joined — not
// whatever the code happens to say months later.
function payoutSplits(c, a) {
if (c.kind !== 'adventure') return [{ userId: a.holderId, cents: c.rewardCents }];
const scored = a.contributions.reduce((m, x) => {
m[x.userId] = (m[x.userId] || 0) + x.weight; return m;
}, {});
const totalW = Object.values(scored).reduce((n, w) => n + w, 0);
if (!totalW) return []; // nobody contributed: refund
const floor = c.pool.minShareCents || 0; // "who contributed and who didn't"
const parts = Object.entries(scored)
.filter(([, w]) => w > 0)
.map(([userId, w]) => ({ userId, cents: Math.max(floor, Math.floor(c.rewardCents * w / totalW)) }));
// Integer money: hand the rounding remainder to the largest contributor rather
// than letting cents evaporate. The sum must equal the pool exactly.
const drift = c.rewardCents - parts.reduce((n, p) => n + p.cents, 0);
if (drift !== 0) parts.sort((x, y) => y.cents - x.cents)[0].cents += drift;
return parts;
}
// ABANDONED / EXPIRED / dispute resolved for the poster.
await stripe.refunds.create(
{ payment_intent: c.escrow.intentId, reason: 'requested_by_customer' },
{ idempotencyKey: `refund_${c.id}` }
);
// If money has already moved, claw the transfer back FIRST, then refund. A refund
// with the transfer still out leaves the platform balance negative — and with
// separate charges and transfers, that is your balance, not the worker's.
await stripe.transfers.createReversal(transferId, { amount: cents },
{ idempotencyKey: `reverse_${assignmentId}_${userId}` });
The client can close the tab, the phone can lose signal, and some payment methods take 2–14 days
to confirm. Never advance the state machine from a client callback.
// POST [internal endpoint] — signature-verified, before any auth gate
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object;
const id = pi.metadata.contractId;
// Idempotent by construction: setting OPEN twice is setting OPEN.
await contracts.transition(id, 'OPEN', {
escrow: { state: 'funded', chargeId: pi.latest_charge, intentId: pi.id },
actor: 'stripe', evt: event.id,
});
break;
}
case 'payment_intent.payment_failed':
await contracts.transition(event.data.object.metadata.contractId, 'DRAFT',
{ reason: 'funding_failed', actor: 'stripe', evt: event.id });
break;
case 'account.updated': {
// payouts_enabled flipping ON is the trigger to retry every parked payout
// for that worker. This is the only path that unsticks them.
const acct = event.data.object;
if (acct.payouts_enabled) await payouts.retryParked(acct.id);
await panda.syncVerification(acct);
break;
}
case 'transfer.reversed':
case 'charge.dispute.created':
await ledger.append(/* … */); await ops.alert(event);
break;
}
Store event.id and reject duplicates — Stripe retries, and a replayed
payment_intent.succeeded must not re-run anything with a side effect.
ifs
// One place where every legal move is written down. Anything not in here cannot
// happen, and "who is allowed" is data rather than a condition buried in a route.
const MOVES = {
DRAFT: { publish: { to: 'OPEN', by: 'poster', guard: 'fundsCharged' } },
OPEN: { apply: { to: 'APPLIED', by: 'worker', guard: 'meetsRequirements' },
cancel: { to: 'EXPIRED', by: 'poster', effect: 'refund' } },
APPLIED: { approve: { to: 'ACCEPTED', by: 'poster' },
reject: { to: 'REJECTED', by: 'poster', needs: 'reason' } },
ACCEPTED: { start: { to: 'IN_PROGRESS', by: 'worker' },
abandon: { to: 'ABANDONED', by: 'worker', effect: 'refund' } },
IN_PROGRESS: { submit: { to: 'SUBMITTED', by: 'worker' } },
SUBMITTED: { accept: { to: 'ACCEPTED_WORK', by: 'poster|timer', effect: 'release' },
revise: { to: 'REVISIONS', by: 'poster', needs: 'reason' },
dispute: { to: 'DISPUTED', by: 'poster|worker', needs: 'reason' } },
REVISIONS: { submit: { to: 'SUBMITTED', by: 'worker' } },
ACCEPTED_WORK: { settle: { to: 'PAID', by: 'system' } },
DISPUTED: { resolve: { to: 'PAID|EXPIRED', by: 'admin', needs: 'reason' } },
};
Every applied move appends { from, to, actor, at, reason, evt } to an append-only ledger. With
real money, that log is what a dispute is decided on, and it only exists if it was written before
anyone needed it.
---
Industry practice for facilitator models is light verification to browse and take low-value work,
with progressive verification triggered by volume, payout amount, or risk signals. Stripe's
embedded onboarding collects identity; you gate the *board* on tier. Practically: a worker can
browse and even work before payout onboarding is finished — but the profile must say clearly and
early that they cannot be paid until it is, not at the moment of payout.
A changed bank account is the classic account-takeover signature. Require re-authentication, cool
off pending payouts for a short window, and notify the old contact method as well as the new one.
Dispute rates, refund patterns, sudden volume changes, and **linkage signals across supposedly
independent accounts** — the same device, card, or bank behind two "different" workers bidding on
the same contract.
---
COUNSEL / CPA. The numbers below are current as of August 2026 and are the ones to confirm.
Stripe issues 1099-K forms only where Stripe controls pricing or the connected account pays
Stripe's fees directly — i.e. controller.fees.payer is account. Where
controller.fees.payer is application (the platform controls pricing, which is this design),
Stripe does not issue a 1099-K and the platform is responsible for filing. Stripe sells a
1099 tax-reporting product that files them for you; using it is strongly advisable over building
it.
For a marketplace paying independent workers for services, the form is 1099-NEC (non-employee
compensation), not 1099-K.
Note a live discrepancy: **Stripe's own Connect tax-reporting page still states $600 for
1099-NEC**, which was the pre-OBBBA threshold. Over-reporting is not an error — filing at $600
when the statutory floor is $2,000 is safe, while the reverse is not. Collect a **W-9 from every
worker at onboarding regardless**, because it is also what supports backup withholding if a TIN is
missing or wrong, and chasing it at year end is miserable.
COUNSEL. As of August 2026 this is genuinely unsettled: the 2024 DOL rule remains in effect
for private litigation while the DOL has stopped enforcing it; a proposed 2026 rule (published
27 Feb 2026, comments closed 28 Apr 2026) would restore the 2021 standard and is not final.
The proposal weights two core factors — control over the work and **opportunity for profit or
loss** — and emphasises actual practice over what a contract says.
And critically: none of this touches state law. California's ABC test and other state
standards apply independently.
What that means for the product, concretely: the further the platform goes toward setting
schedules, dictating method, or preventing workers from declining work or working elsewhere, the
worse the classification position gets — regardless of what the terms say, because the 2026
proposal explicitly looks at practice over paper. Quests and Missions letting workers choose
assignments and set their own approach is not just nicer, it is the defensible design.
---
> "it also should show them how much that will pay out and how long it will take and more data"
Everything below is known at publish, so all of it can be on the card. The rule: **the number
shown biggest is the number that lands in their bank.**
For Adventures add a live share preview — everyone in the party sees their current cut as they
work. The worst possible version of that feature is people finding out at settlement.
---
1. Given a Stripe Connect facilitator model with funds resting on the platform balance between
charge and transfer — do we need money-transmitter licensing in our target states, and does the
agent-of-the-payee exemption apply to us? (§1, §2)
2. Whose contract is the work performed under — ours, or poster-to-worker with us as venue? This
decides the ToS, the dispute process, and most of the classification exposure.
3. Independent-contractor classification given the five kinds, under the current federal position
*and* the states we operate in. (§6.3)
4. 1099-NEC filing obligations and thresholds for TY2026, and whether Stripe's product satisfies
them. (§6.1, §6.2)
5. Terms of service, dispute resolution, and prohibited-work policy.
6. Whether the platform fee changes any of the above.
---