@fantastic.dev/better-stripe 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/dist/client/connect/payoutActivationProof.d.ts +16 -0
- package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
- package/dist/client/connect/payoutActivationProof.js +72 -0
- package/dist/client/connect/payoutActivationProof.js.map +1 -0
- package/dist/client/connect/payoutAdoption.d.ts +35 -0
- package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoption.js +231 -0
- package/dist/client/connect/payoutAdoption.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.js +56 -0
- package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
- package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +2617 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.js +231 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
- package/dist/client/connect/payoutReconciliation.js +4 -2
- package/dist/client/connect/payoutReconciliation.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +7 -1
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +78 -7
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +196 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +38 -4
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/adoption.d.ts +9 -0
- package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
- package/dist/component/payoutPolicy/adoption.js +45 -0
- package/dist/component/payoutPolicy/adoption.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +96 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +38 -5
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts +16 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +299 -2
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +7 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/adoption.d.ts +269 -0
- package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
- package/dist/component/payoutRelease/adoption.js +197 -0
- package/dist/component/payoutRelease/adoption.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +3 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js +20 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +81 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +6 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/observation.d.ts +18 -0
- package/dist/component/payoutRelease/observation.d.ts.map +1 -0
- package/dist/component/payoutRelease/observation.js +11 -0
- package/dist/component/payoutRelease/observation.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +3 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +8 -19
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +3 -9
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +73 -2
- package/docs/settlement.md +127 -40
- package/package.json +1 -1
- package/src/client/connect/payoutActivationProof.ts +101 -0
- package/src/client/connect/payoutAdoption.test.ts +186 -0
- package/src/client/connect/payoutAdoption.ts +331 -0
- package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
- package/src/client/connect/payoutAdoptionAge.ts +74 -0
- package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
- package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
- package/src/client/connect/payoutAdoptionTestHelpers.ts +253 -0
- package/src/client/connect/payoutReconciliation.test.ts +16 -0
- package/src/client/connect/payoutReconciliation.ts +6 -2
- package/src/client/connect/payoutReturnedAdoption.test.ts +157 -0
- package/src/client/payoutPolicy.test.ts +16 -0
- package/src/client/payoutPolicy.ts +107 -5
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +192 -1
- package/src/component/payout-adoption.test.ts +272 -0
- package/src/component/payout-policy-adoption.test.ts +131 -0
- package/src/component/payoutPolicy/adoption.ts +76 -0
- package/src/component/payoutPolicy/mutations.ts +46 -3
- package/src/component/payoutPolicy/schema.ts +9 -0
- package/src/component/payoutRelease/adoption.ts +262 -0
- package/src/component/payoutRelease/helpers.ts +21 -0
- package/src/component/payoutRelease/mutations.ts +10 -0
- package/src/component/payoutRelease/observation.ts +10 -0
- package/src/component/payoutRelease/schema.ts +3 -9
package/docs/settlement.md
CHANGED
|
@@ -135,14 +135,10 @@ Balance Settings API does not expose a numeric payout-floor field; this package
|
|
|
135
135
|
does not invent one. `minimum_balance_by_currency` is a retained reserve after a
|
|
136
136
|
payout, **not a minimum payout trigger**. [Stripe reserve documentation](https://docs.stripe.com/payouts/minimum-balances-for-automatic-payouts).
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
and must account for existing transferred balances.
|
|
143
|
-
- Before bank payout: use a manual payout schedule where the platform has
|
|
144
|
-
permission, and run an automatic application job. Otherwise provider automatic
|
|
145
|
-
sweeps can bypass the application threshold. A retained reserve cannot fix that.
|
|
138
|
+
For a custom threshold, BetterStripe holds funds in the recipient's Stripe
|
|
139
|
+
balance and controls the bank-payout schedule. Verified manual controls and a
|
|
140
|
+
host automatic job prevent provider sweeps from bypassing the threshold. A
|
|
141
|
+
retained reserve cannot serve as the trigger.
|
|
146
142
|
|
|
147
143
|
Manual holding limits depend on country: Thailand 10 days, US 2 years, other
|
|
148
144
|
countries generally 90 days. Schedule permissions depend on account controls and
|
|
@@ -152,7 +148,7 @@ See [payout schedule permissions](https://docs.stripe.com/connect/manage-payout-
|
|
|
152
148
|
and [manual payout restrictions](https://docs.stripe.com/connect/manual-payouts).
|
|
153
149
|
|
|
154
150
|
Custom threshold enforcement is not provided by the snapshot or settlement APIs.
|
|
155
|
-
|
|
151
|
+
Use the recipient-held policy, adoption and release APIs below. Applications must
|
|
156
152
|
not describe a displayed threshold as enforced while Stripe controls automatic
|
|
157
153
|
payouts. A numeric below/exact/above provider-floor demonstration is unavailable
|
|
158
154
|
without a supported provider floor read; surface this capability limitation.
|
|
@@ -183,15 +179,15 @@ obtain control. Stripe also validates update permissions. Before each subsequent
|
|
|
183
179
|
payout attempt, `verify` must confirm live manual settings, unchanged country,
|
|
184
180
|
and an unexpired policy; stored `ready` state alone is insufficient.
|
|
185
181
|
|
|
186
|
-
|
|
187
|
-
in-flight payouts, and
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
182
|
+
Ordinary initial activation requires zero available/pending balances, no past or
|
|
183
|
+
in-flight payouts, and an automatic schedule. Existing balances, payout history
|
|
184
|
+
or a manual schedule return `migration_required`. The ordinary preflight repeats
|
|
185
|
+
before a delayed initial write. For an owned funded account, use the explicit
|
|
186
|
+
[historical import and funded activation flow](#historical-import-and-funded-activation)
|
|
187
|
+
below. Unknown money is never classified as earnings merely because it is
|
|
188
|
+
available. Existing manual schedules still require an authorized migration to a
|
|
189
|
+
known automatic schedule before custom activation; the package does not guess
|
|
190
|
+
which schedule should be restored later.
|
|
195
191
|
|
|
196
192
|
The host must schedule a recurring server job that pages `due(ctx,
|
|
197
193
|
{ cursor: null, numItems: 100 })`, follows its cursor, and calls `expire` for each
|
|
@@ -208,10 +204,9 @@ For opt-out, prepare a new operation with the latest revision,
|
|
|
208
204
|
available after Dashboard control is lost, subject to Stripe permissions. It
|
|
209
205
|
restores only the captured schedule; other balance settings remain untouched.
|
|
210
206
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
enforced until those boundaries and the recurring expiry job are installed.
|
|
207
|
+
The policy/control APIs compose with historical adoption and combined release.
|
|
208
|
+
Install recurring release and expiry jobs before exposing custom scheduling.
|
|
209
|
+
React/example job integration and larger-account collection remain separate work.
|
|
215
210
|
`verify.providerMinimum` is `null`: the Balance Settings API does not expose a
|
|
216
211
|
numeric payout-floor field, and retained balance reserves are not payout triggers.
|
|
217
212
|
|
|
@@ -219,14 +214,13 @@ Sources: [payout schedules](https://docs.stripe.com/connect/manage-payout-schedu
|
|
|
219
214
|
[manual payout holding limits](https://docs.stripe.com/connect/manual-payouts), and
|
|
220
215
|
[Dashboard control limits](https://docs.stripe.com/connect/platform-controls-for-stripe-dashboard-accounts#control-payout-timing).
|
|
221
216
|
|
|
222
|
-
### Transactional
|
|
217
|
+
### Transactional source allocations
|
|
223
218
|
|
|
224
|
-
The
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
prior payouts, independent liabilities, and live payout controls first.
|
|
219
|
+
The package release method owns provider verification and payout creation over
|
|
220
|
+
`payoutRelease` claim and source-allocation tables. Use that method rather than
|
|
221
|
+
calling trusted component mutations with UI totals or an isolated transfer
|
|
222
|
+
receipt. Complete provider history, still-unpaid ownership, prior payouts,
|
|
223
|
+
independent liabilities and live controls must be established together.
|
|
230
224
|
|
|
231
225
|
A claim combines mature creator and affiliate entitlement within one currency.
|
|
232
226
|
It deducts persisted paid and reserved allocations, keeps negative entitlement
|
|
@@ -242,13 +236,11 @@ release an unaccepted claim within a conservative 23-hour window. It requires
|
|
|
242
236
|
its own unique Stripe request receipt; ambiguous errors and older attempts stay
|
|
243
237
|
held. This window stays below Stripe's [24-hour v1 idempotency retention minimum](https://docs.stripe.com/api/idempotent_requests).
|
|
244
238
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
minimums can be presented as an operational feature. In-memory claim tests are
|
|
251
|
-
not evidence of a completed Stripe bank payout.
|
|
239
|
+
Current atomic snapshots support 100 account transfers, 100 historical claims and
|
|
240
|
+
1,000 receipt IDs per source. Larger/incomplete histories hold the whole operation
|
|
241
|
+
without partial totals. Resumable larger-account collection, other provider-debit
|
|
242
|
+
allocation and recurring/UI integration remain required. The package does not
|
|
243
|
+
claim that component tests alone demonstrate a Stripe bank payout.
|
|
252
244
|
|
|
253
245
|
## Verified recipient funding receipts
|
|
254
246
|
|
|
@@ -423,10 +415,105 @@ unless a supported provider contract exposes it.
|
|
|
423
415
|
Current atomic collection accepts up to 100 account transfers and 100 historical
|
|
424
416
|
claims. Larger accounts return `held/snapshot_limit`, without paying a partial
|
|
425
417
|
page. Provider history uses a 1–100 page budget, default 10; exhaustion stays held.
|
|
426
|
-
|
|
427
|
-
independent provider-debit allocation,
|
|
428
|
-
|
|
429
|
-
|
|
418
|
+
Use the historical import and funded activation flow below for known old funds.
|
|
419
|
+
Resumable larger-account collection, independent provider-debit allocation,
|
|
420
|
+
recurring example jobs and React controls remain integration work. Unknown funds
|
|
421
|
+
and unsupported accounts keep explicit migration holds.
|
|
430
422
|
|
|
431
423
|
Sources: [payout creation and source balance selection](https://docs.stripe.com/api/payouts/create),
|
|
432
424
|
[complete balance history and automatic-only payout filtering](https://docs.stripe.com/api/balance_transactions/list).
|
|
425
|
+
|
|
426
|
+
## Historical import and funded activation
|
|
427
|
+
|
|
428
|
+
The host authenticates one recipient account and owner, authorizes historical
|
|
429
|
+
source mappings, and records consent before custom scheduling. Start with an
|
|
430
|
+
owned `stripe_managed` policy at its current revision. If no policy exists,
|
|
431
|
+
prepare/apply a default policy with `expectedRevision: -1`,
|
|
432
|
+
`mode: "stripe_managed"`, and `thresholds: []`. This records ownership and keeps
|
|
433
|
+
the current automatic schedule.
|
|
434
|
+
|
|
435
|
+
Import past payouts whose allocations are not already recorded by BetterStripe:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
const imported = await stripe.payoutPolicy.adoptPayouts(ctx, {
|
|
439
|
+
accountId,
|
|
440
|
+
ownerKey,
|
|
441
|
+
expectedRevision: policy.revision,
|
|
442
|
+
currency: "usd",
|
|
443
|
+
payouts: [
|
|
444
|
+
{
|
|
445
|
+
stripePayoutId: historicalPayoutId,
|
|
446
|
+
allocations: [
|
|
447
|
+
{ stripeTransferId: creatorTransferId, amount: 600 },
|
|
448
|
+
{ stripeTransferId: affiliateTransferId, amount: 400 },
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Amounts use integer settlement-currency minor units. The package does not infer
|
|
456
|
+
manual-payout ownership from a present balance or Stripe's automatic-only payout
|
|
457
|
+
filter. Supply authorized accounting for every unrecorded historical payout in
|
|
458
|
+
the currency. Existing package claims supply their immutable allocations on
|
|
459
|
+
subsequent calls; use `payouts: []` to verify/reconcile already imported history.
|
|
460
|
+
The same flow supports explicit accounting for automatic historical payouts.
|
|
461
|
+
|
|
462
|
+
For a verified bank return, holding-age proof retains the original paid
|
|
463
|
+
principal and adds the returned credit at its availability time. The returned
|
|
464
|
+
claim contributes no currently paid source allocation. Net provider fees still
|
|
465
|
+
reduce the combined unpaid balance. Replaying adoption preserves the same claim;
|
|
466
|
+
a later release can include returned cash and new funding exactly once.
|
|
467
|
+
|
|
468
|
+
The package retrieves actual recipient funding and payout receipts, verifies
|
|
469
|
+
full history against live available/pending cash, and validates each allocation
|
|
470
|
+
against source maturity at the historical payout date. Future credits and
|
|
471
|
+
not-yet-mature bank returns cannot fund an earlier payout. Terminal paid and
|
|
472
|
+
verified returned payouts are supported; pending or ambiguous outcomes stay
|
|
473
|
+
held. Unknown credits, debits, fees or unallocated payouts require further
|
|
474
|
+
reconciliation. All unknown historical payouts import as one component
|
|
475
|
+
transaction, including fees and source allocations. Changed mappings and stale
|
|
476
|
+
competing snapshots reject without mixing their writes. Replays never create a
|
|
477
|
+
payout. Historical paid principal remains paid after later refunds, so debt
|
|
478
|
+
reduces later qualifying funds rather than making the old commission payable
|
|
479
|
+
again. An adopted payout can reconcile a subsequent bank failure without
|
|
480
|
+
requiring a package marker that was absent from its original Stripe object.
|
|
481
|
+
|
|
482
|
+
After successful import, explicitly request funded-account adoption during
|
|
483
|
+
preparation, using the latest owned policy revision:
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
const prepared = await stripe.payoutPolicy.prepare(ctx, {
|
|
487
|
+
accountId,
|
|
488
|
+
ownerKey,
|
|
489
|
+
operationKey,
|
|
490
|
+
expectedRevision: policy.revision,
|
|
491
|
+
mode: "custom",
|
|
492
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
493
|
+
consent: true,
|
|
494
|
+
adoptExistingBalance: true,
|
|
495
|
+
});
|
|
496
|
+
if (prepared.status === "prepared") {
|
|
497
|
+
await stripe.payoutPolicy.apply(ctx, { accountId, ownerKey, operationKey });
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Preparation verifies all settlement currencies, full source membership and prior
|
|
502
|
+
payouts. It repeats provider-history and balance checks before a delayed initial
|
|
503
|
+
attempt, then checks local financial fingerprints under OCC before recording
|
|
504
|
+
the attempt marker. New unallocated history cancels an unattempted activation
|
|
505
|
+
while preserving the automatic schedule. Refresh the policy revision and use a
|
|
506
|
+
new operation key after cancellation. If a manual schedule appears without the
|
|
507
|
+
package's attempt marker, the operation reports schedule drift rather than
|
|
508
|
+
pretending the package performed the change. Provider timeouts retain the
|
|
509
|
+
persisted attempt and immutable schedule request for reconciliation.
|
|
510
|
+
|
|
511
|
+
Holding age follows the unpaid funds. Paid original credits do not age newer
|
|
512
|
+
funds; source debt and verified payout fees consume older ownership first.
|
|
513
|
+
Verified bank-return cash starts a new holding age when it becomes available.
|
|
514
|
+
Preparation cannot restart an expired holding period, and later checks can only
|
|
515
|
+
shorten the pending operation's deadline. `migration_required.adoptionReason`
|
|
516
|
+
explains unknown history or an expired holding period. This flow does not enable
|
|
517
|
+
Dashboard accounts with withdrawal bypasses, Global Payouts, unsupported source
|
|
518
|
+
rails, unknown numeric provider floors or accounts beyond the current atomic
|
|
519
|
+
snapshot bounds. It installs no host cron or UI.
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Infer } from "convex/values";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import type { adoptionProof } from "../../component/payoutPolicy/schema.js";
|
|
5
|
+
import type { claimDoc } from "../../component/payoutRelease/schema.js";
|
|
6
|
+
import { payoutClaimFingerprint } from "../../component/payoutRelease/helpers.js";
|
|
7
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
8
|
+
import { adoptRecipientPayouts } from "./payoutAdoption.js";
|
|
9
|
+
function balanceIdentity(b: Stripe.Balance) {
|
|
10
|
+
return JSON.stringify(
|
|
11
|
+
[b.available, b.pending].map((rows) =>
|
|
12
|
+
rows
|
|
13
|
+
.map((t) => [
|
|
14
|
+
t.currency,
|
|
15
|
+
t.amount,
|
|
16
|
+
Object.entries(t.source_types ?? {}).sort(),
|
|
17
|
+
])
|
|
18
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
function transactionIdentity(t?: Stripe.BalanceTransaction) {
|
|
23
|
+
return t
|
|
24
|
+
? JSON.stringify([
|
|
25
|
+
t.id,
|
|
26
|
+
t.source,
|
|
27
|
+
t.amount,
|
|
28
|
+
t.net,
|
|
29
|
+
t.currency,
|
|
30
|
+
t.status,
|
|
31
|
+
t.available_on,
|
|
32
|
+
])
|
|
33
|
+
: "none";
|
|
34
|
+
}
|
|
35
|
+
/** Complete read/reconcile proof. Unknown historical payouts require explicit import first. */
|
|
36
|
+
export async function collectAdoptionProof(
|
|
37
|
+
stripe: Stripe,
|
|
38
|
+
component: Component,
|
|
39
|
+
ctx: RunCtx,
|
|
40
|
+
scope: { accountId: string; ownerKey: string },
|
|
41
|
+
expectedRevision: number,
|
|
42
|
+
configuredCurrencies: string[],
|
|
43
|
+
): Promise<
|
|
44
|
+
| { status: "verified"; proof: Infer<typeof adoptionProof> }
|
|
45
|
+
| { status: "held"; reason: string }
|
|
46
|
+
> {
|
|
47
|
+
const request = { stripeAccount: scope.accountId };
|
|
48
|
+
const initial = await stripe.balance.retrieve(undefined, request);
|
|
49
|
+
const first = await stripe.balanceTransactions.list({ limit: 1 }, request);
|
|
50
|
+
const queue = new Set([
|
|
51
|
+
...configuredCurrencies,
|
|
52
|
+
...initial.available.map((t) => t.currency),
|
|
53
|
+
...initial.pending.map((t) => t.currency),
|
|
54
|
+
...(first.data[0] ? [first.data[0].currency] : []),
|
|
55
|
+
]);
|
|
56
|
+
const completed = new Set<string>();
|
|
57
|
+
let observations: Infer<typeof adoptionProof>["observations"] = [],
|
|
58
|
+
oldestHeldAt: number | undefined;
|
|
59
|
+
for (const currency of queue) {
|
|
60
|
+
if (completed.size >= 50)
|
|
61
|
+
return { status: "held", reason: "currency_history_incomplete" };
|
|
62
|
+
const result = await adoptRecipientPayouts(
|
|
63
|
+
stripe,
|
|
64
|
+
component,
|
|
65
|
+
ctx,
|
|
66
|
+
{ ...scope, currency, expectedRevision, payouts: [] },
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
if (result.status !== "adopted") return result;
|
|
70
|
+
completed.add(currency);
|
|
71
|
+
for (const c of result.historyCurrencies) queue.add(c);
|
|
72
|
+
observations = result.observations;
|
|
73
|
+
if (result.oldestHeldAt !== undefined)
|
|
74
|
+
oldestHeldAt = Math.min(oldestHeldAt ?? Infinity, result.oldestHeldAt);
|
|
75
|
+
}
|
|
76
|
+
const balance = await stripe.balance.retrieve(undefined, request);
|
|
77
|
+
const latest = await stripe.balanceTransactions.list({ limit: 1 }, request);
|
|
78
|
+
if (
|
|
79
|
+
balanceIdentity(initial) !== balanceIdentity(balance) ||
|
|
80
|
+
transactionIdentity(first.data[0]) !== transactionIdentity(latest.data[0])
|
|
81
|
+
)
|
|
82
|
+
return { status: "held", reason: "provider_changed" };
|
|
83
|
+
const snapshot = (await ctx.runQuery(
|
|
84
|
+
componentRef(component, "payoutRelease/queries/snapshot"),
|
|
85
|
+
{ accountId: scope.accountId, ownerKey: scope.ownerKey },
|
|
86
|
+
)) as
|
|
87
|
+
| { status: "ready"; claims: Infer<typeof claimDoc>[] }
|
|
88
|
+
| { status: "held"; reason: string };
|
|
89
|
+
if (snapshot.status !== "ready") return snapshot;
|
|
90
|
+
return {
|
|
91
|
+
status: "verified",
|
|
92
|
+
proof: {
|
|
93
|
+
observations,
|
|
94
|
+
claims: snapshot.claims.map((c) => ({
|
|
95
|
+
operationKey: c.operationKey,
|
|
96
|
+
fingerprint: payoutClaimFingerprint(c),
|
|
97
|
+
})),
|
|
98
|
+
...(oldestHeldAt !== undefined ? { oldestHeldAt } : {}),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type Stripe from "stripe";
|
|
4
|
+
import {
|
|
5
|
+
component,
|
|
6
|
+
scope,
|
|
7
|
+
setup,
|
|
8
|
+
historical,
|
|
9
|
+
} from "./payoutAdoptionTestHelpers.js";
|
|
10
|
+
it("imports a verified historical manual payout with exact source allocations", async () => {
|
|
11
|
+
const s = await historical();
|
|
12
|
+
expect(await s.adopt()).toMatchObject({
|
|
13
|
+
status: "adopted",
|
|
14
|
+
claims: [{ historical: true, status: "paid", amount: 1000 }],
|
|
15
|
+
});
|
|
16
|
+
await s.adopt();
|
|
17
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
18
|
+
expect(
|
|
19
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
20
|
+
).toMatchObject([
|
|
21
|
+
{ paidAmount: 600, reservedAmount: 0 },
|
|
22
|
+
{ paidAmount: 400, reservedAmount: 0 },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
it("requires explicit historical allocations instead of inferring ownership from balance", async () => {
|
|
26
|
+
const s = await historical();
|
|
27
|
+
expect(await s.adopt({ payouts: [] })).toMatchObject({
|
|
28
|
+
status: "held",
|
|
29
|
+
reason: "historical_allocation_required",
|
|
30
|
+
});
|
|
31
|
+
expect(
|
|
32
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
33
|
+
).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
it("rejects unavailable funding without writing any partial import", async () => {
|
|
36
|
+
const s = await historical();
|
|
37
|
+
s.setTransactions(
|
|
38
|
+
s
|
|
39
|
+
.transactions()
|
|
40
|
+
.map((t) =>
|
|
41
|
+
t.source === "py_1"
|
|
42
|
+
? { ...t, available_on: Math.floor(Date.now() / 1000) + 100 }
|
|
43
|
+
: t,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
await expect(s.adopt()).rejects.toThrow(/funding/i);
|
|
47
|
+
expect(
|
|
48
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
it("holds unrelated history before writing source allocations", async () => {
|
|
52
|
+
const s = await historical();
|
|
53
|
+
s.setTransactions([
|
|
54
|
+
...s.transactions(),
|
|
55
|
+
{ ...s.transactions()[0], id: "txn_unowned", source: "po_unknown" },
|
|
56
|
+
]);
|
|
57
|
+
expect(await s.adopt()).toMatchObject({
|
|
58
|
+
status: "held",
|
|
59
|
+
reason: "unallocated_history",
|
|
60
|
+
});
|
|
61
|
+
expect(
|
|
62
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
63
|
+
).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
async function activation() {
|
|
66
|
+
const s = await setup();
|
|
67
|
+
const now = Math.floor(Date.now() / 1000);
|
|
68
|
+
s.setTransactions(
|
|
69
|
+
s
|
|
70
|
+
.transactions()
|
|
71
|
+
.map((t) => ({ ...t, created: now - 100, available_on: now - 50 })),
|
|
72
|
+
);
|
|
73
|
+
let schedule: Record<string, unknown> = {
|
|
74
|
+
interval: "weekly",
|
|
75
|
+
weekly_payout_days: ["monday"],
|
|
76
|
+
};
|
|
77
|
+
const settings = {
|
|
78
|
+
retrieve: vi.fn(async () => ({
|
|
79
|
+
payments: { payouts: { status: "enabled", schedule } },
|
|
80
|
+
})),
|
|
81
|
+
update: vi.fn(
|
|
82
|
+
async (args: {
|
|
83
|
+
payments: { payouts: { schedule: Record<string, unknown> } };
|
|
84
|
+
}) => {
|
|
85
|
+
schedule = args.payments.payouts.schedule;
|
|
86
|
+
return { payments: { payouts: { status: "enabled", schedule } } };
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
Object.assign(s.provider, {
|
|
91
|
+
v2: {
|
|
92
|
+
core: {
|
|
93
|
+
accounts: {
|
|
94
|
+
retrieve: vi.fn(async () => ({
|
|
95
|
+
id: scope.accountId,
|
|
96
|
+
dashboard: "none",
|
|
97
|
+
identity: { country: "US" },
|
|
98
|
+
})),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
balanceSettings: settings,
|
|
103
|
+
});
|
|
104
|
+
const { PayoutPolicyClient } = await import("../payoutPolicy.js");
|
|
105
|
+
const client = new PayoutPolicyClient(
|
|
106
|
+
component,
|
|
107
|
+
() => s.provider as unknown as Stripe,
|
|
108
|
+
);
|
|
109
|
+
const opts = {
|
|
110
|
+
accountId: scope.accountId,
|
|
111
|
+
ownerKey: scope.ownerKey,
|
|
112
|
+
operationKey: "activate",
|
|
113
|
+
expectedRevision: 0,
|
|
114
|
+
mode: "custom" as const,
|
|
115
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
116
|
+
consent: true as const,
|
|
117
|
+
adoptExistingBalance: true as const,
|
|
118
|
+
};
|
|
119
|
+
return { ...s, client, settings, opts };
|
|
120
|
+
}
|
|
121
|
+
it("explicit funded activation verifies complete history, then releases through the public package", async () => {
|
|
122
|
+
const s = await activation();
|
|
123
|
+
expect(await s.client.prepare(s.ctx, s.opts)).toMatchObject({
|
|
124
|
+
status: "prepared",
|
|
125
|
+
operation: { adoption: { oldestHeldAt: expect.any(Number) } },
|
|
126
|
+
});
|
|
127
|
+
expect(
|
|
128
|
+
await s.client.apply(s.ctx, { ...scope, operationKey: "activate" }),
|
|
129
|
+
).toMatchObject({ status: "ready" });
|
|
130
|
+
expect(await s.client.release(s.ctx, scope)).toMatchObject({
|
|
131
|
+
status: "updated",
|
|
132
|
+
claim: { status: "paid", amount: 1000 },
|
|
133
|
+
});
|
|
134
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
it("new unallocated provider history before a delayed attempt cancels activation safely", async () => {
|
|
137
|
+
const s = await activation();
|
|
138
|
+
await s.client.prepare(s.ctx, s.opts);
|
|
139
|
+
s.setTransactions([
|
|
140
|
+
...s.transactions(),
|
|
141
|
+
{
|
|
142
|
+
...s.transactions()[0],
|
|
143
|
+
id: "txn_unknown",
|
|
144
|
+
source: "py_unknown",
|
|
145
|
+
amount: 10,
|
|
146
|
+
net: 10,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
expect(
|
|
150
|
+
await s.client.apply(s.ctx, { ...scope, operationKey: "activate" }),
|
|
151
|
+
).toMatchObject({
|
|
152
|
+
status: "migration_required",
|
|
153
|
+
adoptionReason: "unallocated_history",
|
|
154
|
+
});
|
|
155
|
+
expect(s.settings.update).not.toHaveBeenCalled();
|
|
156
|
+
expect(await s.client.get(s.ctx, scope)).toMatchObject({
|
|
157
|
+
mode: "stripe_managed",
|
|
158
|
+
status: "ready",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
it("funded activation remains explicit", async () => {
|
|
162
|
+
const s = await activation();
|
|
163
|
+
expect(
|
|
164
|
+
await s.client.prepare(s.ctx, {
|
|
165
|
+
...s.opts,
|
|
166
|
+
adoptExistingBalance: undefined,
|
|
167
|
+
}),
|
|
168
|
+
).toMatchObject({ status: "migration_required", reason: "existing_balance" });
|
|
169
|
+
expect(s.settings.update).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
it("cannot restart the holding period for already-expired funds", async () => {
|
|
172
|
+
const s = await activation();
|
|
173
|
+
s.setTransactions(
|
|
174
|
+
s.transactions().map((t) => ({ ...t, created: 100, available_on: 200 })),
|
|
175
|
+
);
|
|
176
|
+
expect(await s.client.prepare(s.ctx, s.opts)).toMatchObject({
|
|
177
|
+
status: "migration_required",
|
|
178
|
+
adoptionReason: "holding_deadline_expired",
|
|
179
|
+
});
|
|
180
|
+
expect(await s.client.get(s.ctx, scope)).toMatchObject({
|
|
181
|
+
mode: "stripe_managed",
|
|
182
|
+
status: "ready",
|
|
183
|
+
revision: 0,
|
|
184
|
+
});
|
|
185
|
+
expect(s.settings.update).not.toHaveBeenCalled();
|
|
186
|
+
});
|