@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -1
  3. package/dist/client/connect/payouts.d.ts +3 -2
  4. package/dist/client/connect/payouts.d.ts.map +1 -1
  5. package/dist/client/connect/payouts.js.map +1 -1
  6. package/dist/client/index.d.ts +4 -8
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +5 -0
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/client/payoutPolicy.d.ts +81 -0
  11. package/dist/client/payoutPolicy.d.ts.map +1 -0
  12. package/dist/client/payoutPolicy.js +262 -0
  13. package/dist/client/payoutPolicy.js.map +1 -0
  14. package/dist/component/_generated/component.d.ts +273 -0
  15. package/dist/component/_generated/component.d.ts.map +1 -1
  16. package/dist/component/earnings/triggers.d.ts +150 -0
  17. package/dist/component/earnings/triggers.d.ts.map +1 -1
  18. package/dist/component/payoutPolicy/helpers.d.ts +51 -0
  19. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
  20. package/dist/component/payoutPolicy/helpers.js +68 -0
  21. package/dist/component/payoutPolicy/helpers.js.map +1 -0
  22. package/dist/component/payoutPolicy/mutations.d.ts +172 -0
  23. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
  24. package/dist/component/payoutPolicy/mutations.js +191 -0
  25. package/dist/component/payoutPolicy/mutations.js.map +1 -0
  26. package/dist/component/payoutPolicy/queries.d.ts +97 -0
  27. package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
  28. package/dist/component/payoutPolicy/queries.js +51 -0
  29. package/dist/component/payoutPolicy/queries.js.map +1 -0
  30. package/dist/component/payoutPolicy/schema.d.ts +396 -0
  31. package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
  32. package/dist/component/payoutPolicy/schema.js +54 -0
  33. package/dist/component/payoutPolicy/schema.js.map +1 -0
  34. package/dist/component/schema.d.ts +131 -0
  35. package/dist/component/schema.js +3 -0
  36. package/dist/component/schema.js.map +1 -1
  37. package/docs/settlement.md +62 -0
  38. package/package.json +1 -1
  39. package/src/client/connect/payouts.ts +10 -8
  40. package/src/client/index.ts +7 -11
  41. package/src/client/payoutPolicy.test.ts +320 -0
  42. package/src/client/payoutPolicy.ts +409 -0
  43. package/src/component/_generated/api.ts +6 -0
  44. package/src/component/_generated/component.ts +280 -0
  45. package/src/component/payout-policy.test.ts +248 -0
  46. package/src/component/payoutPolicy/helpers.ts +88 -0
  47. package/src/component/payoutPolicy/mutations.ts +225 -0
  48. package/src/component/payoutPolicy/queries.ts +56 -0
  49. package/src/component/payoutPolicy/schema.ts +67 -0
  50. package/src/component/schema.ts +3 -0
@@ -0,0 +1,409 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "./helpers.js";
4
+ import { runMutationOrThrow } from "./helpers.js";
5
+ import { componentRef } from "./webhooks/helpers.js";
6
+ import type {
7
+ policyDoc,
8
+ operationDoc,
9
+ schedule,
10
+ } from "../component/payoutPolicy/schema.js";
11
+ import {
12
+ canonicalSchedule,
13
+ canonicalThresholds,
14
+ validateScope,
15
+ } from "../component/payoutPolicy/helpers.js";
16
+ export type PayoutPolicy = Infer<typeof policyDoc>;
17
+ export type PayoutPolicyOperation = Infer<typeof operationDoc>;
18
+ export type PayoutPolicyScope = { accountId: string; ownerKey: string };
19
+ export type PayoutPolicyOptions = PayoutPolicyScope & {
20
+ operationKey: string;
21
+ expectedRevision: number;
22
+ mode: PayoutPolicy["mode"];
23
+ thresholds: PayoutPolicy["thresholds"];
24
+ consent?: true;
25
+ };
26
+ export type PayoutPolicyLimitation = {
27
+ status: "unsupported" | "migration_required";
28
+ reason:
29
+ | "provider_permission_denied"
30
+ | "payout_control_unverified"
31
+ | "country_unavailable"
32
+ | "payouts_unavailable"
33
+ | "existing_balance"
34
+ | "in_flight_payouts"
35
+ | "existing_manual_schedule";
36
+ };
37
+ export type PayoutSchedule = Infer<typeof schedule>;
38
+ function readSchedule(settings: Stripe.BalanceSettings): PayoutSchedule {
39
+ const s = settings.payments.payouts?.schedule;
40
+ if (
41
+ !s?.interval ||
42
+ !["daily", "weekly", "monthly", "manual"].includes(s.interval)
43
+ )
44
+ throw new Error("Provider payout schedule is unavailable.");
45
+ return canonicalSchedule({
46
+ interval: s.interval as PayoutSchedule["interval"],
47
+ weeklyDays: s.weekly_payout_days,
48
+ monthlyDays: s.monthly_payout_days,
49
+ });
50
+ }
51
+ function permissionDenied(error: unknown): PayoutPolicyLimitation | null {
52
+ return error instanceof Error &&
53
+ "type" in error &&
54
+ error.type === "StripePermissionError"
55
+ ? { status: "unsupported", reason: "provider_permission_denied" }
56
+ : null;
57
+ }
58
+ const equal = (a: unknown, b: unknown) =>
59
+ JSON.stringify(a) === JSON.stringify(b);
60
+ const toProvider = (
61
+ s: PayoutSchedule,
62
+ ): Stripe.BalanceSettingsUpdateParams.Payments.Payouts.Schedule => ({
63
+ interval: s.interval,
64
+ ...(s.weeklyDays ? { weekly_payout_days: s.weeklyDays } : {}),
65
+ ...(s.monthlyDays ? { monthly_payout_days: s.monthlyDays } : {}),
66
+ });
67
+ /** Host authenticates owner/policy/consent. Provider I/O stays in this package. */
68
+ export class PayoutPolicyClient {
69
+ constructor(
70
+ private readonly component: Component,
71
+ private readonly provider: () => Stripe,
72
+ ) {}
73
+ async get(
74
+ ctx: RunCtx,
75
+ scope: PayoutPolicyScope,
76
+ ): Promise<PayoutPolicy | null> {
77
+ validateScope(scope);
78
+ return ctx.runQuery(
79
+ componentRef(this.component, "payoutPolicy/queries/get"),
80
+ { accountId: scope.accountId, ownerKey: scope.ownerKey },
81
+ );
82
+ }
83
+ async operation(
84
+ ctx: RunCtx,
85
+ args: PayoutPolicyScope & { operationKey: string },
86
+ ): Promise<PayoutPolicyOperation | null> {
87
+ validateScope(args);
88
+ return ctx.runQuery(
89
+ componentRef(this.component, "payoutPolicy/queries/operation"),
90
+ {
91
+ accountId: args.accountId,
92
+ ownerKey: args.ownerKey,
93
+ operationKey: args.operationKey,
94
+ },
95
+ );
96
+ }
97
+ async due(
98
+ ctx: RunCtx,
99
+ paginationOpts: { cursor: string | null; numItems: number },
100
+ ): Promise<{
101
+ page: PayoutPolicy[];
102
+ continueCursor: string;
103
+ isDone: boolean;
104
+ }> {
105
+ return ctx.runQuery(
106
+ componentRef(this.component, "payoutPolicy/queries/due"),
107
+ { now: Date.now(), paginationOpts },
108
+ );
109
+ }
110
+ private async readControls(
111
+ scope: PayoutPolicyScope,
112
+ requireExclusive = true,
113
+ previousCountry?: string,
114
+ ): Promise<
115
+ { country: string; schedule: PayoutSchedule } | PayoutPolicyLimitation
116
+ > {
117
+ const stripe = this.provider();
118
+ const account = await stripe.v2.core.accounts.retrieve(scope.accountId, {
119
+ include: ["identity"],
120
+ });
121
+ if (account.id !== scope.accountId)
122
+ throw new Error("Provider payout account identity does not match.");
123
+ if (account.closed || (requireExclusive && account.dashboard !== "none"))
124
+ return { status: "unsupported", reason: "payout_control_unverified" };
125
+ const country = account.identity?.country ?? previousCountry;
126
+ if (!country || !/^[A-Z]{2}$/.test(country))
127
+ return { status: "unsupported", reason: "country_unavailable" };
128
+ const settings = await stripe.balanceSettings.retrieve(undefined, {
129
+ stripeAccount: scope.accountId,
130
+ });
131
+ if (requireExclusive && settings.payments.payouts?.status !== "enabled")
132
+ return { status: "unsupported", reason: "payouts_unavailable" };
133
+ return {
134
+ country,
135
+ schedule: readSchedule(settings),
136
+ };
137
+ }
138
+ private async controls(
139
+ scope: PayoutPolicyScope,
140
+ requireExclusive = true,
141
+ previousCountry?: string,
142
+ ) {
143
+ try {
144
+ return await this.readControls(scope, requireExclusive, previousCountry);
145
+ } catch (error) {
146
+ const denied = permissionDenied(error);
147
+ if (denied) return denied;
148
+ throw error;
149
+ }
150
+ }
151
+ private async migrationPreflight(
152
+ scope: PayoutPolicyScope,
153
+ ): Promise<PayoutPolicyLimitation | null> {
154
+ const stripe = this.provider();
155
+ const balance = await stripe.balance.retrieve(undefined, {
156
+ stripeAccount: scope.accountId,
157
+ });
158
+ if (
159
+ [...balance.available, ...balance.pending].some(
160
+ (value) => value.amount !== 0,
161
+ )
162
+ )
163
+ return { status: "migration_required", reason: "existing_balance" };
164
+ for (const status of ["pending", "in_transit"] as const) {
165
+ const payouts = await stripe.payouts.list(
166
+ { status, limit: 1 },
167
+ { stripeAccount: scope.accountId },
168
+ );
169
+ if (payouts.has_more || payouts.data.length)
170
+ return { status: "migration_required", reason: "in_flight_payouts" };
171
+ }
172
+ return null;
173
+ }
174
+ /** Persist restoration intent first. Existing mixed funds require explicit adoption; never infer ownership. */
175
+ async prepare(
176
+ ctx: RunCtx,
177
+ opts: PayoutPolicyOptions,
178
+ ): Promise<
179
+ | { status: "prepared"; operation: PayoutPolicyOperation }
180
+ | PayoutPolicyLimitation
181
+ > {
182
+ const observedAt = Date.now();
183
+ validateScope(opts);
184
+ if (opts.mode !== "custom" && opts.mode !== "stripe_managed")
185
+ throw new Error("Invalid payout policy mode.");
186
+ if (
187
+ !Number.isSafeInteger(opts.expectedRevision) ||
188
+ opts.expectedRevision < -1
189
+ )
190
+ throw new Error("Invalid payout policy revision.");
191
+ if (
192
+ !opts.operationKey?.trim() ||
193
+ opts.operationKey.length > 180 ||
194
+ /[\r\n]/.test(opts.operationKey)
195
+ )
196
+ throw new Error("Invalid payout policy operation identity.");
197
+ if (opts.mode === "custom" && opts.consent !== true)
198
+ throw new Error(
199
+ "Explicit consent is required for custom payout scheduling.",
200
+ );
201
+ const thresholds = canonicalThresholds(
202
+ opts.thresholds,
203
+ opts.mode === "custom",
204
+ );
205
+ const prior = await this.operation(ctx, opts);
206
+ if (prior) {
207
+ if (
208
+ prior.mode !== opts.mode ||
209
+ prior.expectedRevision !== opts.expectedRevision ||
210
+ !equal(prior.thresholds, thresholds)
211
+ )
212
+ throw new Error("Payout policy operation identity cannot be reused.");
213
+ return { status: "prepared", operation: prior };
214
+ }
215
+ const policy = await this.get(ctx, opts);
216
+ if (policy?.status === "changing")
217
+ throw new Error("A payout policy operation is pending.");
218
+ if ((policy?.revision ?? -1) !== opts.expectedRevision)
219
+ throw new Error("Payout policy revision changed.");
220
+ const controls = await this.controls(
221
+ opts,
222
+ opts.mode === "custom",
223
+ policy?.country,
224
+ );
225
+ if ("status" in controls) return controls;
226
+ if (opts.mode === "custom" && policy?.mode !== "custom") {
227
+ if (controls.schedule.interval === "manual")
228
+ return {
229
+ status: "migration_required",
230
+ reason: "existing_manual_schedule",
231
+ };
232
+ const migration = await this.migrationPreflight(opts);
233
+ if (migration) return migration;
234
+ }
235
+ const operation = (await runMutationOrThrow(
236
+ ctx,
237
+ componentRef(this.component, "payoutPolicy/mutations/prepare"),
238
+ {
239
+ accountId: opts.accountId,
240
+ ownerKey: opts.ownerKey,
241
+ operationKey: opts.operationKey,
242
+ expectedRevision: opts.expectedRevision,
243
+ mode: opts.mode,
244
+ thresholds,
245
+ country: controls.country,
246
+ observedSchedule: controls.schedule,
247
+ observedAt,
248
+ },
249
+ )) as PayoutPolicyOperation;
250
+ return { status: "prepared", operation };
251
+ }
252
+ /** Unknown provider outcomes remain pending; retry uses the persisted target and request identity. */
253
+ async apply(
254
+ ctx: RunCtx,
255
+ args: PayoutPolicyScope & { operationKey: string },
256
+ ): Promise<
257
+ | { status: "ready"; operation: PayoutPolicyOperation }
258
+ | { status: "schedule_drift" }
259
+ | { status: "canceled"; operation: PayoutPolicyOperation }
260
+ | PayoutPolicyLimitation
261
+ > {
262
+ const operation = await this.operation(ctx, args);
263
+ if (!operation) throw new Error("Payout policy operation is unavailable.");
264
+ if (operation.status === "complete") return { status: "ready", operation };
265
+ if (operation.status === "canceled")
266
+ return { status: "canceled", operation };
267
+ const controls = await this.controls(
268
+ args,
269
+ operation.mode === "custom",
270
+ operation.country,
271
+ );
272
+ const policy = await this.get(ctx, args);
273
+ if (
274
+ operation.mode === "custom" &&
275
+ !operation.attempted &&
276
+ policy?.holdDeadline &&
277
+ policy.holdDeadline <= Date.now() &&
278
+ !("status" in controls) &&
279
+ equal(controls.schedule, operation.observedSchedule)
280
+ ) {
281
+ const canceled = (await runMutationOrThrow(
282
+ ctx,
283
+ componentRef(
284
+ this.component,
285
+ "payoutPolicy/mutations/cancelUnattempted",
286
+ ),
287
+ { ...args, observedSchedule: controls.schedule },
288
+ )) as PayoutPolicyOperation;
289
+ return { status: "canceled", operation: canceled };
290
+ }
291
+ if ("status" in controls) return controls;
292
+ if (operation.mode === "custom" && controls.country !== operation.country)
293
+ return { status: "schedule_drift" };
294
+ if (!equal(controls.schedule, operation.targetSchedule)) {
295
+ if (!equal(controls.schedule, operation.observedSchedule))
296
+ return { status: "schedule_drift" };
297
+ if (
298
+ operation.mode === "custom" &&
299
+ !operation.attempted &&
300
+ policy?.mode === "stripe_managed"
301
+ ) {
302
+ const migration = await this.migrationPreflight(args);
303
+ if (migration) {
304
+ await runMutationOrThrow(
305
+ ctx,
306
+ componentRef(
307
+ this.component,
308
+ "payoutPolicy/mutations/cancelUnattempted",
309
+ ),
310
+ { ...args, observedSchedule: controls.schedule },
311
+ );
312
+ return migration;
313
+ }
314
+ }
315
+ await runMutationOrThrow(
316
+ ctx,
317
+ componentRef(this.component, "payoutPolicy/mutations/beginAttempt"),
318
+ args,
319
+ );
320
+ try {
321
+ await this.provider().balanceSettings.update(
322
+ {
323
+ payments: {
324
+ payouts: { schedule: toProvider(operation.targetSchedule) },
325
+ },
326
+ },
327
+ {
328
+ stripeAccount: args.accountId,
329
+ idempotencyKey: `bs:payout-policy:${operation.operationKey}`,
330
+ },
331
+ );
332
+ } catch (error) {
333
+ const denied = permissionDenied(error);
334
+ if (denied) return denied;
335
+ throw error;
336
+ }
337
+ }
338
+ const verified = await this.controls(
339
+ args,
340
+ operation.mode === "custom",
341
+ operation.country,
342
+ );
343
+ if ("status" in verified) return verified;
344
+ if (
345
+ (operation.mode === "custom" && verified.country !== operation.country) ||
346
+ !equal(verified.schedule, operation.targetSchedule)
347
+ )
348
+ return { status: "schedule_drift" };
349
+ const confirmed = (await runMutationOrThrow(
350
+ ctx,
351
+ componentRef(this.component, "payoutPolicy/mutations/confirm"),
352
+ { ...args, observedSchedule: verified.schedule },
353
+ )) as PayoutPolicyOperation;
354
+ return { status: "ready", operation: confirmed };
355
+ }
356
+ /** Call before every scheduled payout; stored ready state alone is not proof of live controls. */
357
+ async verify(
358
+ ctx: RunCtx,
359
+ scope: PayoutPolicyScope,
360
+ ): Promise<
361
+ | { status: "verified"; policy: PayoutPolicy; providerMinimum: null }
362
+ | {
363
+ status:
364
+ "not_configured" | "pending_operation" | "schedule_drift" | "expired";
365
+ }
366
+ | PayoutPolicyLimitation
367
+ > {
368
+ const policy = await this.get(ctx, scope);
369
+ if (policy?.status === "changing") return { status: "pending_operation" };
370
+ if (!policy || policy.mode !== "custom")
371
+ return { status: "not_configured" };
372
+ if (!policy.holdDeadline || policy.holdDeadline <= Date.now())
373
+ return { status: "expired" };
374
+ const controls = await this.controls(scope);
375
+ if ("status" in controls) return controls;
376
+ if (
377
+ controls.country !== policy.country ||
378
+ controls.schedule.interval !== "manual"
379
+ )
380
+ return { status: "schedule_drift" };
381
+ return { status: "verified", policy, providerMinimum: null };
382
+ }
383
+ /** A host cron must page due() and call expire(); payout execution is a separate boundary. */
384
+ async expire(ctx: RunCtx, scope: PayoutPolicyScope) {
385
+ let policy = await this.get(ctx, scope);
386
+ if (!policy?.holdDeadline || policy.holdDeadline > Date.now())
387
+ return { status: "not_due" as const };
388
+ if (policy.status === "changing") {
389
+ const result = await this.apply(ctx, {
390
+ ...scope,
391
+ operationKey: policy.operationKey,
392
+ });
393
+ if (result.status !== "ready") return result;
394
+ policy = await this.get(ctx, scope);
395
+ }
396
+ if (!policy || policy.mode !== "custom")
397
+ return { status: "not_due" as const };
398
+ const operationKey = `bs:payout-expiry:${scope.accountId}:${policy.revision}`;
399
+ const prepared = await this.prepare(ctx, {
400
+ ...scope,
401
+ operationKey,
402
+ expectedRevision: policy.revision,
403
+ mode: "stripe_managed",
404
+ thresholds: [],
405
+ });
406
+ if (prepared.status !== "prepared") return prepared;
407
+ return this.apply(ctx, { ...scope, operationKey });
408
+ }
409
+ }
@@ -34,6 +34,9 @@ import type * as lib_idempotency from "../lib/idempotency.js";
34
34
  import type * as lib_paymentSeller from "../lib/paymentSeller.js";
35
35
  import type * as lib_reversals from "../lib/reversals.js";
36
36
  import type * as lib_stripeFactory from "../lib/stripeFactory.js";
37
+ import type * as payoutPolicy_helpers from "../payoutPolicy/helpers.js";
38
+ import type * as payoutPolicy_mutations from "../payoutPolicy/mutations.js";
39
+ import type * as payoutPolicy_queries from "../payoutPolicy/queries.js";
37
40
  import type * as products_mutations from "../products/mutations.js";
38
41
  import type * as products_queries from "../products/queries.js";
39
42
  import type * as products_validators from "../products/validators.js";
@@ -78,6 +81,9 @@ const fullApi: ApiFromModules<{
78
81
  "lib/paymentSeller": typeof lib_paymentSeller;
79
82
  "lib/reversals": typeof lib_reversals;
80
83
  "lib/stripeFactory": typeof lib_stripeFactory;
84
+ "payoutPolicy/helpers": typeof payoutPolicy_helpers;
85
+ "payoutPolicy/mutations": typeof payoutPolicy_mutations;
86
+ "payoutPolicy/queries": typeof payoutPolicy_queries;
81
87
  "products/mutations": typeof products_mutations;
82
88
  "products/queries": typeof products_queries;
83
89
  "products/validators": typeof products_validators;