@fantastic.dev/better-stripe 0.17.1 → 0.18.0
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 +6 -0
- package/README.md +7 -1
- package/dist/client/connect/payouts.d.ts +3 -2
- package/dist/client/connect/payouts.d.ts.map +1 -1
- package/dist/client/connect/payouts.js.map +1 -1
- package/dist/client/index.d.ts +4 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +81 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -0
- package/dist/client/payoutPolicy.js +262 -0
- package/dist/client/payoutPolicy.js.map +1 -0
- package/dist/component/_generated/component.d.ts +273 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +150 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts +51 -0
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
- package/dist/component/payoutPolicy/helpers.js +68 -0
- package/dist/component/payoutPolicy/helpers.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +172 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
- package/dist/component/payoutPolicy/mutations.js +191 -0
- package/dist/component/payoutPolicy/mutations.js.map +1 -0
- package/dist/component/payoutPolicy/queries.d.ts +97 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
- package/dist/component/payoutPolicy/queries.js +51 -0
- package/dist/component/payoutPolicy/queries.js.map +1 -0
- package/dist/component/payoutPolicy/schema.d.ts +396 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
- package/dist/component/payoutPolicy/schema.js +54 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -0
- package/dist/component/schema.d.ts +131 -0
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +62 -0
- package/package.json +1 -1
- package/src/client/connect/payouts.ts +10 -8
- package/src/client/index.ts +7 -11
- package/src/client/payoutPolicy.test.ts +320 -0
- package/src/client/payoutPolicy.ts +409 -0
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +280 -0
- package/src/component/payout-policy.test.ts +248 -0
- package/src/component/payoutPolicy/helpers.ts +88 -0
- package/src/component/payoutPolicy/mutations.ts +225 -0
- package/src/component/payoutPolicy/queries.ts +56 -0
- package/src/component/payoutPolicy/schema.ts +67 -0
- package/src/component/schema.ts +3 -0
package/dist/component/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { policiesTable, operationsTable } from "./payoutPolicy/schema.js";
|
|
1
2
|
import { currencyTotalsTable } from "./earnings/schema.js";
|
|
2
3
|
import { claimsTable, balancesTable, groupsTable, reservationsTable, } from "./costRecovery/schema";
|
|
3
4
|
import { allocationsTable, commissionTermsTable, commissionDecisionsTable, } from "./settlement/schema";
|
|
@@ -8,6 +9,8 @@ import { accountsTable } from "./core/schema";
|
|
|
8
9
|
import { pricesTable, productsTable } from "./products/schema";
|
|
9
10
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
10
11
|
export default defineSchema({
|
|
12
|
+
payoutPolicies: policiesTable,
|
|
13
|
+
payoutPolicyOperations: operationsTable,
|
|
11
14
|
currencyTotals: currencyTotalsTable,
|
|
12
15
|
recoveryClaims: claimsTable,
|
|
13
16
|
recoveryBalances: balancesTable,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EACL,WAAW,EACX,aAAa,EACb,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,aAAa,EACb,YAAY,EACZ,sBAAsB,EACtB,YAAY,EACZ,wBAAwB,EACxB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,eAAe,YAAY,CAAC;IAC1B,cAAc,EAAE,mBAAmB;IACnC,cAAc,EAAE,WAAW;IAC3B,gBAAgB,EAAE,aAAa;IAC/B,cAAc,EAAE,WAAW;IAC3B,oBAAoB,EAAE,iBAAiB;IACvC,qBAAqB,EAAE,gBAAgB;IACvC,eAAe,EAAE,oBAAoB;IACrC,mBAAmB,EAAE,wBAAwB;IAC7C,OAAO;IACP,QAAQ,EAAE,aAAa;IAEvB,WAAW;IACX,QAAQ,EAAE,aAAa;IACvB,MAAM,EAAE,WAAW;IAEnB,UAAU;IACV,aAAa,EAAE,kBAAkB;IACjC,gBAAgB,EAAE,qBAAqB;IACvC,QAAQ,EAAE,aAAa;IAEvB,UAAU;IACV,QAAQ,EAAE,aAAa;IACvB,OAAO,EAAE,YAAY;IACrB,iBAAiB,EAAE,sBAAsB;IACzC,OAAO,EAAE,YAAY;IACrB,QAAQ,EAAE,aAAa;IACvB,SAAS,EAAE,cAAc;IACzB,mBAAmB,EAAE,wBAAwB;IAE7C,WAAW;IACX,aAAa,EAAE,kBAAkB;CAClC,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EACL,WAAW,EACX,aAAa,EACb,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,aAAa,EACb,YAAY,EACZ,sBAAsB,EACtB,YAAY,EACZ,wBAAwB,EACxB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,eAAe,YAAY,CAAC;IAC1B,cAAc,EAAE,aAAa;IAC7B,sBAAsB,EAAE,eAAe;IACvC,cAAc,EAAE,mBAAmB;IACnC,cAAc,EAAE,WAAW;IAC3B,gBAAgB,EAAE,aAAa;IAC/B,cAAc,EAAE,WAAW;IAC3B,oBAAoB,EAAE,iBAAiB;IACvC,qBAAqB,EAAE,gBAAgB;IACvC,eAAe,EAAE,oBAAoB;IACrC,mBAAmB,EAAE,wBAAwB;IAC7C,OAAO;IACP,QAAQ,EAAE,aAAa;IAEvB,WAAW;IACX,QAAQ,EAAE,aAAa;IACvB,MAAM,EAAE,WAAW;IAEnB,UAAU;IACV,aAAa,EAAE,kBAAkB;IACjC,gBAAgB,EAAE,qBAAqB;IACvC,QAAQ,EAAE,aAAa;IAEvB,UAAU;IACV,QAAQ,EAAE,aAAa;IACvB,OAAO,EAAE,YAAY;IACrB,iBAAiB,EAAE,sBAAsB;IACzC,OAAO,EAAE,YAAY;IACrB,QAAQ,EAAE,aAAa;IACvB,SAAS,EAAE,cAAc;IACzB,mBAAmB,EAAE,wBAAwB;IAE7C,WAAW;IACX,aAAa,EAAE,kBAAkB;CAClC,CAAC,CAAC"}
|
package/docs/settlement.md
CHANGED
|
@@ -156,3 +156,65 @@ It requires a selected retention boundary and migration policy. Applications mus
|
|
|
156
156
|
not describe a displayed threshold as enforced while Stripe controls automatic
|
|
157
157
|
payouts. A numeric below/exact/above provider-floor demonstration is unavailable
|
|
158
158
|
without a supported provider floor read; surface this capability limitation.
|
|
159
|
+
|
|
160
|
+
## Recipient-held payout policy controls
|
|
161
|
+
|
|
162
|
+
`stripe.payoutPolicy` stages versioned, account-wide schedule changes for a
|
|
163
|
+
user-level recipient. The host authenticates `ownerKey`, authorizes the policy,
|
|
164
|
+
and records explicit consent. Thresholds use integer minor units per currency;
|
|
165
|
+
they combine creator and affiliate funds rather than creating category gates.
|
|
166
|
+
The untouched default remains Stripe-managed automatic payouts.
|
|
167
|
+
|
|
168
|
+
Call `prepare(ctx, { accountId, ownerKey, operationKey, expectedRevision, mode:
|
|
169
|
+
"custom", thresholds: [{ currency: "usd", amount: 1000 }], consent: true })` in
|
|
170
|
+
an authorized action. Revision `-1` means no existing policy. Persisted preparation
|
|
171
|
+
captures the original payout schedule and country holding deadline before any
|
|
172
|
+
provider update. If preparation returns `prepared`, call `apply` with the same
|
|
173
|
+
account, owner and operation key. Replays preserve the immutable request;
|
|
174
|
+
competing operations and stale revisions are rejected. A provider timeout or
|
|
175
|
+
failed confirmation stays pending for reconciliation, rather than being treated
|
|
176
|
+
as a completed schedule change.
|
|
177
|
+
|
|
178
|
+
These APIs currently require a verified `dashboard: none` account and enabled
|
|
179
|
+
payouts for custom activation. Accounts with Dashboard withdrawal access return
|
|
180
|
+
an explicit control limitation: restricting schedule edits alone cannot prevent
|
|
181
|
+
manual withdrawals. The API never changes dashboard access or loss liability to
|
|
182
|
+
obtain control. Stripe also validates update permissions. Before each subsequent
|
|
183
|
+
payout attempt, `verify` must confirm live manual settings, unchanged country,
|
|
184
|
+
and an unexpired policy; stored `ready` state alone is insufficient.
|
|
185
|
+
|
|
186
|
+
Initial activation rejects existing nonzero available or pending balances,
|
|
187
|
+
in-flight payouts, and existing manual schedules with `migration_required`.
|
|
188
|
+
The preflight is repeated before an initial schedule update; a delayed activation
|
|
189
|
+
with newly arrived funds is canceled before changing Stripe. This is a bounded
|
|
190
|
+
preflight, not an ownership inference or automatic adoption.
|
|
191
|
+
Already-mixed funds need a reconciled source ledger and explicit adoption before
|
|
192
|
+
custom release can be enabled. Zero-balance activation does not classify later
|
|
193
|
+
unrelated deposits as earnings. Payout claims must use that source ledger and a
|
|
194
|
+
fresh provider balance when the separate release boundary is integrated.
|
|
195
|
+
|
|
196
|
+
The host must schedule a recurring server job that pages `due(ctx,
|
|
197
|
+
{ cursor: null, numItems: 100 })`, follows its cursor, and calls `expire` for each
|
|
198
|
+
returned account/owner. `expire` reconciles pending operations and restores the
|
|
199
|
+
original automatic schedule when the holding deadline is reached. Unattempted
|
|
200
|
+
expired enables are canceled without changing Stripe. Failed/unknown restores
|
|
201
|
+
remain visible and retryable; alert operators if they cannot complete. Editing
|
|
202
|
+
thresholds never extends the original deadline. Current limits are 10 days for
|
|
203
|
+
Thailand, a conservative 730 days for the US, and 90 days elsewhere; verify
|
|
204
|
+
current provider rules before enabling the feature in production.
|
|
205
|
+
|
|
206
|
+
For opt-out, prepare a new operation with the latest revision,
|
|
207
|
+
`mode: "stripe_managed"`, and `thresholds: []`, then apply it. Restoration remains
|
|
208
|
+
available after Dashboard control is lost, subject to Stripe permissions. It
|
|
209
|
+
restores only the captured schedule; other balance settings remain untouched.
|
|
210
|
+
|
|
211
|
+
This is the durable policy/control boundary. Automatic combined payout execution,
|
|
212
|
+
adoption of existing balances, and its UI integration are separate work and are
|
|
213
|
+
not supplied by these schedule APIs. Do not expose a custom minimum as fully
|
|
214
|
+
enforced until those boundaries and the recurring expiry job are installed.
|
|
215
|
+
`verify.providerMinimum` is `null`: the Balance Settings API does not expose a
|
|
216
|
+
numeric payout-floor field, and retained balance reserves are not payout triggers.
|
|
217
|
+
|
|
218
|
+
Sources: [payout schedules](https://docs.stripe.com/connect/manage-payout-schedule),
|
|
219
|
+
[manual payout holding limits](https://docs.stripe.com/connect/manual-payouts), and
|
|
220
|
+
[Dashboard control limits](https://docs.stripe.com/connect/platform-controls-for-stripe-dashboard-accounts#control-payout-timing).
|
package/package.json
CHANGED
|
@@ -53,17 +53,19 @@ export async function getAccountBalance(
|
|
|
53
53
|
return [...byCurrency.values()];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export type CreatePayoutOptions = {
|
|
57
|
+
stripeAccountId: string;
|
|
58
|
+
amount: number;
|
|
59
|
+
currency?: string;
|
|
60
|
+
metadata?: Record<string, string>;
|
|
61
|
+
/** Stripe idempotency key for retry-safe payout creation. */
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
56
65
|
export async function createPayout(
|
|
57
66
|
stripe: Stripe,
|
|
58
67
|
_ctx: RunCtx,
|
|
59
|
-
opts:
|
|
60
|
-
stripeAccountId: string;
|
|
61
|
-
amount: number;
|
|
62
|
-
currency?: string;
|
|
63
|
-
metadata?: Record<string, string>;
|
|
64
|
-
/** Stripe idempotency key for retry-safe payout creation. */
|
|
65
|
-
idempotencyKey?: string;
|
|
66
|
-
},
|
|
68
|
+
opts: CreatePayoutOptions,
|
|
67
69
|
) {
|
|
68
70
|
const payout = await stripe.payouts.create(
|
|
69
71
|
{
|
package/src/client/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PayoutPolicyClient } from "./payoutPolicy.js";
|
|
2
|
+
export * from "./payoutPolicy.js";
|
|
1
3
|
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
2
4
|
export type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
3
5
|
import * as intentsImpl from "./billing/intents.js";
|
|
@@ -1146,17 +1148,7 @@ export class BetterStripe extends ReadContractClient {
|
|
|
1146
1148
|
|
|
1147
1149
|
// PAYOUT METHODS
|
|
1148
1150
|
|
|
1149
|
-
async createPayout(
|
|
1150
|
-
ctx: RunCtx,
|
|
1151
|
-
opts: {
|
|
1152
|
-
stripeAccountId: string;
|
|
1153
|
-
amount: number;
|
|
1154
|
-
currency?: string;
|
|
1155
|
-
metadata?: Record<string, string>;
|
|
1156
|
-
/** Stripe idempotency key for retry-safe payout creation. */
|
|
1157
|
-
idempotencyKey?: string;
|
|
1158
|
-
},
|
|
1159
|
-
) {
|
|
1151
|
+
async createPayout(ctx: RunCtx, opts: payoutsImpl.CreatePayoutOptions) {
|
|
1160
1152
|
return payoutsImpl.createPayout(this.stripe(), ctx, opts);
|
|
1161
1153
|
}
|
|
1162
1154
|
|
|
@@ -1263,6 +1255,10 @@ export class BetterStripe extends ReadContractClient {
|
|
|
1263
1255
|
) {
|
|
1264
1256
|
return providerReads.getCouponFromStripe(this.stripe(), opts);
|
|
1265
1257
|
}
|
|
1258
|
+
get payoutPolicy() {
|
|
1259
|
+
return new PayoutPolicyClient(this.component, () => this.stripe());
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1266
1262
|
get costRecovery() {
|
|
1267
1263
|
return new CostRecoveryClient(this.component, () => this.stripe());
|
|
1268
1264
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference, type FunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import type Stripe from "stripe";
|
|
6
|
+
import type { Component, RunCtx } from "./helpers.js";
|
|
7
|
+
import schema from "../component/schema.js";
|
|
8
|
+
const modules = import.meta.glob("../component/**/*.*s");
|
|
9
|
+
const symbol = Symbol.for("toReferencePath");
|
|
10
|
+
const ref = (path: string) => ({ [symbol]: `betterStripe/${path}` });
|
|
11
|
+
const component = {
|
|
12
|
+
payoutPolicy: { queries: { get: ref("payoutPolicy/queries/get") } },
|
|
13
|
+
} as unknown as Component;
|
|
14
|
+
const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
|
|
15
|
+
const options = {
|
|
16
|
+
...scope,
|
|
17
|
+
operationKey: "enable",
|
|
18
|
+
expectedRevision: -1,
|
|
19
|
+
mode: "custom" as const,
|
|
20
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
21
|
+
consent: true as const,
|
|
22
|
+
};
|
|
23
|
+
async function feature() {
|
|
24
|
+
const mod = (await import("./payoutPolicy.js").catch(() => ({}))) as {
|
|
25
|
+
PayoutPolicyClient?: typeof import("./payoutPolicy.js").PayoutPolicyClient;
|
|
26
|
+
};
|
|
27
|
+
expect(
|
|
28
|
+
mod.PayoutPolicyClient,
|
|
29
|
+
"public payout-policy client is missing",
|
|
30
|
+
).toBeTypeOf("function");
|
|
31
|
+
return mod.PayoutPolicyClient!;
|
|
32
|
+
}
|
|
33
|
+
async function setup() {
|
|
34
|
+
const t = convexTest(schema, modules);
|
|
35
|
+
const path = (r: Record<symbol, string>) =>
|
|
36
|
+
r[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
37
|
+
const ctx = {
|
|
38
|
+
runQuery: async (
|
|
39
|
+
r: Record<symbol, string>,
|
|
40
|
+
args: Record<string, unknown>,
|
|
41
|
+
) => t.query(makeFunctionReference<"query">(path(r)), args),
|
|
42
|
+
runMutation: async (
|
|
43
|
+
r: Record<symbol, string>,
|
|
44
|
+
args: Record<string, unknown>,
|
|
45
|
+
) => t.mutation(makeFunctionReference<"mutation">(path(r)), args),
|
|
46
|
+
} as unknown as RunCtx;
|
|
47
|
+
let schedule: Record<string, unknown> = {
|
|
48
|
+
interval: "weekly",
|
|
49
|
+
weekly_payout_days: ["monday"],
|
|
50
|
+
};
|
|
51
|
+
const provider = {
|
|
52
|
+
v2: {
|
|
53
|
+
core: {
|
|
54
|
+
accounts: {
|
|
55
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
56
|
+
id: scope.accountId,
|
|
57
|
+
dashboard: "none",
|
|
58
|
+
identity: { country: "US" },
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
balance: {
|
|
64
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
65
|
+
available: [{ currency: "usd", amount: 0 }],
|
|
66
|
+
pending: [],
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
payouts: { list: vi.fn().mockResolvedValue({ data: [], has_more: false }) },
|
|
70
|
+
balanceSettings: {
|
|
71
|
+
retrieve: vi.fn(async () => ({
|
|
72
|
+
payments: { payouts: { status: "enabled", schedule } },
|
|
73
|
+
})),
|
|
74
|
+
update: vi.fn(
|
|
75
|
+
async (
|
|
76
|
+
args: {
|
|
77
|
+
payments: { payouts: { schedule: Record<string, unknown> } };
|
|
78
|
+
},
|
|
79
|
+
_request?: unknown,
|
|
80
|
+
) => {
|
|
81
|
+
schedule = args.payments.payouts.schedule;
|
|
82
|
+
return { payments: { payouts: { status: "enabled", schedule } } };
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const Client = await feature();
|
|
88
|
+
const client = new Client(component, () => provider as unknown as Stripe);
|
|
89
|
+
return {
|
|
90
|
+
t,
|
|
91
|
+
ctx,
|
|
92
|
+
provider,
|
|
93
|
+
client,
|
|
94
|
+
setSchedule: (value: Record<string, unknown>) => {
|
|
95
|
+
schedule = value;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
it("persists original provider settings, verifies manual schedule and restores opt-out", async () => {
|
|
100
|
+
const { client, ctx, provider } = await setup();
|
|
101
|
+
const prepared = await client.prepare(ctx, options);
|
|
102
|
+
expect(prepared.status).toBe("prepared");
|
|
103
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
104
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
105
|
+
const applied = await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
106
|
+
expect(applied.status).toBe("ready");
|
|
107
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
108
|
+
status: "verified",
|
|
109
|
+
providerMinimum: null,
|
|
110
|
+
});
|
|
111
|
+
await client.prepare(ctx, {
|
|
112
|
+
...options,
|
|
113
|
+
operationKey: "disable",
|
|
114
|
+
expectedRevision: 0,
|
|
115
|
+
mode: "stripe_managed",
|
|
116
|
+
thresholds: [],
|
|
117
|
+
consent: undefined,
|
|
118
|
+
});
|
|
119
|
+
await client.apply(ctx, { ...scope, operationKey: "disable" });
|
|
120
|
+
expect(provider.balanceSettings.update.mock.calls[1][0]).toMatchObject({
|
|
121
|
+
payments: {
|
|
122
|
+
payouts: {
|
|
123
|
+
schedule: { interval: "weekly", weekly_payout_days: ["monday"] },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
128
|
+
});
|
|
129
|
+
it("recovers provider success after a failed persistence write without changing the request", async () => {
|
|
130
|
+
const { client, ctx, provider } = await setup();
|
|
131
|
+
await client.prepare(ctx, options);
|
|
132
|
+
const original = ctx.runMutation!;
|
|
133
|
+
let fail = true;
|
|
134
|
+
ctx.runMutation = (async (
|
|
135
|
+
ref: FunctionReference<"mutation", "public" | "internal">,
|
|
136
|
+
args: Record<string, unknown>,
|
|
137
|
+
) => {
|
|
138
|
+
if (
|
|
139
|
+
fail &&
|
|
140
|
+
(ref as unknown as Record<symbol, string>)[symbol].endsWith("/confirm")
|
|
141
|
+
) {
|
|
142
|
+
fail = false;
|
|
143
|
+
throw new Error("write failed");
|
|
144
|
+
}
|
|
145
|
+
return original(ref, args);
|
|
146
|
+
}) as RunCtx["runMutation"];
|
|
147
|
+
await expect(
|
|
148
|
+
client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
149
|
+
).rejects.toThrow("write failed");
|
|
150
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
151
|
+
expect(
|
|
152
|
+
(await client.apply(ctx, { ...scope, operationKey: "enable" })).status,
|
|
153
|
+
).toBe("ready");
|
|
154
|
+
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(provider.balanceSettings.update.mock.calls[0][1]).toMatchObject({
|
|
156
|
+
stripeAccount: scope.accountId,
|
|
157
|
+
idempotencyKey: expect.any(String),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it("does not adopt arbitrary existing balances or in-flight payouts as earnings", async () => {
|
|
161
|
+
const { client, ctx, provider } = await setup();
|
|
162
|
+
provider.balance.retrieve.mockResolvedValueOnce({
|
|
163
|
+
available: [{ currency: "usd", amount: 100 }],
|
|
164
|
+
pending: [],
|
|
165
|
+
});
|
|
166
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
167
|
+
status: "migration_required",
|
|
168
|
+
reason: "existing_balance",
|
|
169
|
+
});
|
|
170
|
+
expect(await client.get(ctx, scope))!.toBeNull();
|
|
171
|
+
provider.payouts.list.mockResolvedValueOnce({
|
|
172
|
+
data: [{ id: "po_old" }],
|
|
173
|
+
has_more: false,
|
|
174
|
+
} as never);
|
|
175
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
176
|
+
status: "migration_required",
|
|
177
|
+
reason: "in_flight_payouts",
|
|
178
|
+
});
|
|
179
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
it("returns a limitation for Dashboard withdrawal bypass and never changes account liability", async () => {
|
|
182
|
+
const { client, ctx, provider } = await setup();
|
|
183
|
+
provider.v2.core.accounts.retrieve.mockResolvedValueOnce({
|
|
184
|
+
id: scope.accountId,
|
|
185
|
+
dashboard: "full",
|
|
186
|
+
identity: { country: "US" },
|
|
187
|
+
});
|
|
188
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
189
|
+
status: "unsupported",
|
|
190
|
+
reason: "payout_control_unverified",
|
|
191
|
+
});
|
|
192
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
it("fails closed on drift and leaves an unknown provider write pending for retry", async () => {
|
|
195
|
+
const { client, ctx, provider, setSchedule } = await setup();
|
|
196
|
+
await client.prepare(ctx, options);
|
|
197
|
+
provider.balanceSettings.update.mockRejectedValueOnce(
|
|
198
|
+
new Error("network unavailable"),
|
|
199
|
+
);
|
|
200
|
+
await expect(
|
|
201
|
+
client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
202
|
+
).rejects.toThrow("network unavailable");
|
|
203
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
204
|
+
setSchedule({ interval: "daily" });
|
|
205
|
+
expect(
|
|
206
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
207
|
+
).toMatchObject({ status: "schedule_drift" });
|
|
208
|
+
});
|
|
209
|
+
it("requires consent and scope/version validation before provider access", async () => {
|
|
210
|
+
const { client, ctx, provider } = await setup();
|
|
211
|
+
await expect(
|
|
212
|
+
client.prepare(ctx, { ...options, consent: undefined }),
|
|
213
|
+
).rejects.toThrow(/consent/);
|
|
214
|
+
await expect(
|
|
215
|
+
client.prepare(ctx, { ...options, expectedRevision: 0.5 }),
|
|
216
|
+
).rejects.toThrow(/revision/);
|
|
217
|
+
expect(provider.v2.core.accounts.retrieve).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
it("restores original settings at the holding deadline, without extending it", async () => {
|
|
220
|
+
const { client, ctx, t, provider } = await setup();
|
|
221
|
+
await client.prepare(ctx, options);
|
|
222
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
223
|
+
expect((await client.expire(ctx, scope)).status).toBe("not_due");
|
|
224
|
+
await t.run(async (c) => {
|
|
225
|
+
const p = await c.db.query("payoutPolicies").first();
|
|
226
|
+
await c.db.patch(p!._id, { holdDeadline: Date.now() - 1 });
|
|
227
|
+
});
|
|
228
|
+
expect((await client.expire(ctx, scope)).status).toBe("ready");
|
|
229
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
230
|
+
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(2);
|
|
231
|
+
});
|
|
232
|
+
it("re-checks live control on every payout-policy verification", async () => {
|
|
233
|
+
const { client, ctx, setSchedule } = await setup();
|
|
234
|
+
await client.prepare(ctx, options);
|
|
235
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
236
|
+
setSchedule({ interval: "daily" });
|
|
237
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
238
|
+
status: "schedule_drift",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
it("reports a staged initial enable as pending rather than unconfigured", async () => {
|
|
242
|
+
const { client, ctx } = await setup();
|
|
243
|
+
await client.prepare(ctx, options);
|
|
244
|
+
expect(await client.verify(ctx, scope)).toMatchObject({
|
|
245
|
+
status: "pending_operation",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
it("restores opt-out even after exclusive Dashboard control is lost", async () => {
|
|
249
|
+
const { client, ctx, provider } = await setup();
|
|
250
|
+
await client.prepare(ctx, options);
|
|
251
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
252
|
+
provider.v2.core.accounts.retrieve.mockResolvedValue({
|
|
253
|
+
id: scope.accountId,
|
|
254
|
+
dashboard: "full",
|
|
255
|
+
identity: { country: "US" },
|
|
256
|
+
});
|
|
257
|
+
expect(
|
|
258
|
+
(
|
|
259
|
+
await client.prepare(ctx, {
|
|
260
|
+
...options,
|
|
261
|
+
operationKey: "off-lost-control",
|
|
262
|
+
expectedRevision: 0,
|
|
263
|
+
mode: "stripe_managed",
|
|
264
|
+
thresholds: [],
|
|
265
|
+
})
|
|
266
|
+
).status,
|
|
267
|
+
).toBe("prepared");
|
|
268
|
+
expect(
|
|
269
|
+
(await client.apply(ctx, { ...scope, operationKey: "off-lost-control" }))
|
|
270
|
+
.status,
|
|
271
|
+
).toBe("ready");
|
|
272
|
+
});
|
|
273
|
+
it("cancels an expired, unattempted enable without switching a live schedule to manual", async () => {
|
|
274
|
+
const { client, ctx, t, provider } = await setup();
|
|
275
|
+
await client.prepare(ctx, options);
|
|
276
|
+
await t.run(async (c) => {
|
|
277
|
+
const p = await c.db.query("payoutPolicies").first();
|
|
278
|
+
await c.db.patch(p!._id, { holdDeadline: Date.now() - 1 });
|
|
279
|
+
});
|
|
280
|
+
await client.expire(ctx, scope);
|
|
281
|
+
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
282
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
it("reports explicit Stripe permission denials and keeps uncertain operations held", async () => {
|
|
285
|
+
const { client, ctx, provider } = await setup();
|
|
286
|
+
const denied = () =>
|
|
287
|
+
Object.assign(new Error("permission denied"), {
|
|
288
|
+
type: "StripePermissionError",
|
|
289
|
+
});
|
|
290
|
+
provider.v2.core.accounts.retrieve.mockRejectedValueOnce(denied());
|
|
291
|
+
expect(await client.prepare(ctx, options)).toMatchObject({
|
|
292
|
+
status: "unsupported",
|
|
293
|
+
reason: "provider_permission_denied",
|
|
294
|
+
});
|
|
295
|
+
await client.prepare(ctx, options);
|
|
296
|
+
provider.balanceSettings.update.mockRejectedValueOnce(denied());
|
|
297
|
+
expect(
|
|
298
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
299
|
+
).toMatchObject({
|
|
300
|
+
status: "unsupported",
|
|
301
|
+
reason: "provider_permission_denied",
|
|
302
|
+
});
|
|
303
|
+
expect((await client.get(ctx, scope))!.status).toBe("changing");
|
|
304
|
+
});
|
|
305
|
+
it("rechecks delayed activation and cancels safely when funds arrive after preparation", async () => {
|
|
306
|
+
const { client, ctx, provider } = await setup();
|
|
307
|
+
await client.prepare(ctx, options);
|
|
308
|
+
provider.balance.retrieve.mockResolvedValueOnce({
|
|
309
|
+
available: [{ currency: "usd", amount: 100 }],
|
|
310
|
+
pending: [],
|
|
311
|
+
});
|
|
312
|
+
expect(
|
|
313
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" }),
|
|
314
|
+
).toMatchObject({ status: "migration_required", reason: "existing_balance" });
|
|
315
|
+
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
316
|
+
expect(await client.get(ctx, scope)).toMatchObject({
|
|
317
|
+
mode: "stripe_managed",
|
|
318
|
+
status: "ready",
|
|
319
|
+
});
|
|
320
|
+
});
|