@fantastic.dev/better-stripe 0.17.0 → 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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +58 -9
  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/connect/queries.d.ts.map +1 -1
  17. package/dist/component/connect/queries.js +2 -1
  18. package/dist/component/connect/queries.js.map +1 -1
  19. package/dist/component/earnings/arithmetic.d.ts +3 -0
  20. package/dist/component/earnings/arithmetic.d.ts.map +1 -0
  21. package/dist/component/earnings/arithmetic.js +11 -0
  22. package/dist/component/earnings/arithmetic.js.map +1 -0
  23. package/dist/component/earnings/reads.d.ts.map +1 -1
  24. package/dist/component/earnings/reads.js +6 -0
  25. package/dist/component/earnings/reads.js.map +1 -1
  26. package/dist/component/earnings/triggers.d.ts +150 -0
  27. package/dist/component/earnings/triggers.d.ts.map +1 -1
  28. package/dist/component/earnings/triggers.js +17 -12
  29. package/dist/component/earnings/triggers.js.map +1 -1
  30. package/dist/component/payoutPolicy/helpers.d.ts +51 -0
  31. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
  32. package/dist/component/payoutPolicy/helpers.js +68 -0
  33. package/dist/component/payoutPolicy/helpers.js.map +1 -0
  34. package/dist/component/payoutPolicy/mutations.d.ts +172 -0
  35. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
  36. package/dist/component/payoutPolicy/mutations.js +191 -0
  37. package/dist/component/payoutPolicy/mutations.js.map +1 -0
  38. package/dist/component/payoutPolicy/queries.d.ts +97 -0
  39. package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
  40. package/dist/component/payoutPolicy/queries.js +51 -0
  41. package/dist/component/payoutPolicy/queries.js.map +1 -0
  42. package/dist/component/payoutPolicy/schema.d.ts +396 -0
  43. package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
  44. package/dist/component/payoutPolicy/schema.js +54 -0
  45. package/dist/component/payoutPolicy/schema.js.map +1 -0
  46. package/dist/component/schema.d.ts +131 -0
  47. package/dist/component/schema.js +3 -0
  48. package/dist/component/schema.js.map +1 -1
  49. package/docs/react-workflows.md +5 -3
  50. package/docs/read-contracts.md +13 -0
  51. package/docs/settlement.md +62 -0
  52. package/package.json +1 -1
  53. package/src/client/connect/payouts.ts +10 -8
  54. package/src/client/index.ts +7 -11
  55. package/src/client/payoutPolicy.test.ts +320 -0
  56. package/src/client/payoutPolicy.ts +409 -0
  57. package/src/component/_generated/api.ts +8 -0
  58. package/src/component/_generated/component.ts +280 -0
  59. package/src/component/connect/queries.ts +2 -1
  60. package/src/component/earnings/arithmetic.ts +10 -0
  61. package/src/component/earnings/reads.ts +6 -0
  62. package/src/component/earnings/triggers.ts +18 -13
  63. package/src/component/payout-policy.test.ts +248 -0
  64. package/src/component/payoutPolicy/helpers.ts +88 -0
  65. package/src/component/payoutPolicy/mutations.ts +225 -0
  66. package/src/component/payoutPolicy/queries.ts +56 -0
  67. package/src/component/payoutPolicy/schema.ts +67 -0
  68. package/src/component/schema.ts +3 -0
  69. package/src/component/summary-arithmetic.test.ts +246 -0
@@ -2194,6 +2194,286 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2194
2194
  >;
2195
2195
  };
2196
2196
  };
2197
+ payoutPolicy: {
2198
+ mutations: {
2199
+ beginAttempt: FunctionReference<
2200
+ "mutation",
2201
+ "internal",
2202
+ { accountId: string; operationKey: string; ownerKey: string },
2203
+ {
2204
+ _creationTime: number;
2205
+ _id: string;
2206
+ accountId: string;
2207
+ attempted?: boolean;
2208
+ country: string;
2209
+ expectedRevision: number;
2210
+ mode: "stripe_managed" | "custom";
2211
+ observedAt?: number;
2212
+ observedSchedule: {
2213
+ interval: "daily" | "weekly" | "monthly" | "manual";
2214
+ monthlyDays?: Array<number>;
2215
+ weeklyDays?: Array<string>;
2216
+ };
2217
+ operationKey: string;
2218
+ ownerKey: string;
2219
+ previousSchedule: {
2220
+ interval: "daily" | "weekly" | "monthly" | "manual";
2221
+ monthlyDays?: Array<number>;
2222
+ weeklyDays?: Array<string>;
2223
+ };
2224
+ revision: number;
2225
+ status: "pending" | "complete" | "canceled";
2226
+ targetSchedule: {
2227
+ interval: "daily" | "weekly" | "monthly" | "manual";
2228
+ monthlyDays?: Array<number>;
2229
+ weeklyDays?: Array<string>;
2230
+ };
2231
+ thresholds: Array<{ amount: number; currency: string }>;
2232
+ },
2233
+ Name
2234
+ >;
2235
+ cancelUnattempted: FunctionReference<
2236
+ "mutation",
2237
+ "internal",
2238
+ {
2239
+ accountId: string;
2240
+ observedSchedule: {
2241
+ interval: "daily" | "weekly" | "monthly" | "manual";
2242
+ monthlyDays?: Array<number>;
2243
+ weeklyDays?: Array<string>;
2244
+ };
2245
+ operationKey: string;
2246
+ ownerKey: string;
2247
+ },
2248
+ {
2249
+ _creationTime: number;
2250
+ _id: string;
2251
+ accountId: string;
2252
+ attempted?: boolean;
2253
+ country: string;
2254
+ expectedRevision: number;
2255
+ mode: "stripe_managed" | "custom";
2256
+ observedAt?: number;
2257
+ observedSchedule: {
2258
+ interval: "daily" | "weekly" | "monthly" | "manual";
2259
+ monthlyDays?: Array<number>;
2260
+ weeklyDays?: Array<string>;
2261
+ };
2262
+ operationKey: string;
2263
+ ownerKey: string;
2264
+ previousSchedule: {
2265
+ interval: "daily" | "weekly" | "monthly" | "manual";
2266
+ monthlyDays?: Array<number>;
2267
+ weeklyDays?: Array<string>;
2268
+ };
2269
+ revision: number;
2270
+ status: "pending" | "complete" | "canceled";
2271
+ targetSchedule: {
2272
+ interval: "daily" | "weekly" | "monthly" | "manual";
2273
+ monthlyDays?: Array<number>;
2274
+ weeklyDays?: Array<string>;
2275
+ };
2276
+ thresholds: Array<{ amount: number; currency: string }>;
2277
+ },
2278
+ Name
2279
+ >;
2280
+ confirm: FunctionReference<
2281
+ "mutation",
2282
+ "internal",
2283
+ {
2284
+ accountId: string;
2285
+ observedSchedule: {
2286
+ interval: "daily" | "weekly" | "monthly" | "manual";
2287
+ monthlyDays?: Array<number>;
2288
+ weeklyDays?: Array<string>;
2289
+ };
2290
+ operationKey: string;
2291
+ ownerKey: string;
2292
+ },
2293
+ {
2294
+ _creationTime: number;
2295
+ _id: string;
2296
+ accountId: string;
2297
+ attempted?: boolean;
2298
+ country: string;
2299
+ expectedRevision: number;
2300
+ mode: "stripe_managed" | "custom";
2301
+ observedAt?: number;
2302
+ observedSchedule: {
2303
+ interval: "daily" | "weekly" | "monthly" | "manual";
2304
+ monthlyDays?: Array<number>;
2305
+ weeklyDays?: Array<string>;
2306
+ };
2307
+ operationKey: string;
2308
+ ownerKey: string;
2309
+ previousSchedule: {
2310
+ interval: "daily" | "weekly" | "monthly" | "manual";
2311
+ monthlyDays?: Array<number>;
2312
+ weeklyDays?: Array<string>;
2313
+ };
2314
+ revision: number;
2315
+ status: "pending" | "complete" | "canceled";
2316
+ targetSchedule: {
2317
+ interval: "daily" | "weekly" | "monthly" | "manual";
2318
+ monthlyDays?: Array<number>;
2319
+ weeklyDays?: Array<string>;
2320
+ };
2321
+ thresholds: Array<{ amount: number; currency: string }>;
2322
+ },
2323
+ Name
2324
+ >;
2325
+ prepare: FunctionReference<
2326
+ "mutation",
2327
+ "internal",
2328
+ {
2329
+ accountId: string;
2330
+ country: string;
2331
+ expectedRevision: number;
2332
+ mode: "stripe_managed" | "custom";
2333
+ observedAt?: number;
2334
+ observedSchedule: {
2335
+ interval: "daily" | "weekly" | "monthly" | "manual";
2336
+ monthlyDays?: Array<number>;
2337
+ weeklyDays?: Array<string>;
2338
+ };
2339
+ operationKey: string;
2340
+ ownerKey: string;
2341
+ thresholds: Array<{ amount: number; currency: string }>;
2342
+ },
2343
+ {
2344
+ _creationTime: number;
2345
+ _id: string;
2346
+ accountId: string;
2347
+ attempted?: boolean;
2348
+ country: string;
2349
+ expectedRevision: number;
2350
+ mode: "stripe_managed" | "custom";
2351
+ observedAt?: number;
2352
+ observedSchedule: {
2353
+ interval: "daily" | "weekly" | "monthly" | "manual";
2354
+ monthlyDays?: Array<number>;
2355
+ weeklyDays?: Array<string>;
2356
+ };
2357
+ operationKey: string;
2358
+ ownerKey: string;
2359
+ previousSchedule: {
2360
+ interval: "daily" | "weekly" | "monthly" | "manual";
2361
+ monthlyDays?: Array<number>;
2362
+ weeklyDays?: Array<string>;
2363
+ };
2364
+ revision: number;
2365
+ status: "pending" | "complete" | "canceled";
2366
+ targetSchedule: {
2367
+ interval: "daily" | "weekly" | "monthly" | "manual";
2368
+ monthlyDays?: Array<number>;
2369
+ weeklyDays?: Array<string>;
2370
+ };
2371
+ thresholds: Array<{ amount: number; currency: string }>;
2372
+ },
2373
+ Name
2374
+ >;
2375
+ };
2376
+ queries: {
2377
+ due: FunctionReference<
2378
+ "query",
2379
+ "internal",
2380
+ {
2381
+ now: number;
2382
+ paginationOpts: {
2383
+ cursor: string | null;
2384
+ endCursor?: string | null;
2385
+ id?: number;
2386
+ maximumBytesRead?: number;
2387
+ maximumRowsRead?: number;
2388
+ numItems: number;
2389
+ };
2390
+ },
2391
+ {
2392
+ continueCursor: string;
2393
+ isDone: boolean;
2394
+ page: Array<{
2395
+ _creationTime: number;
2396
+ _id: string;
2397
+ accountId: string;
2398
+ country: string;
2399
+ holdDeadline?: number;
2400
+ mode: "stripe_managed" | "custom";
2401
+ operationKey: string;
2402
+ ownerKey: string;
2403
+ previousSchedule?: {
2404
+ interval: "daily" | "weekly" | "monthly" | "manual";
2405
+ monthlyDays?: Array<number>;
2406
+ weeklyDays?: Array<string>;
2407
+ };
2408
+ revision: number;
2409
+ status: "changing" | "ready";
2410
+ thresholds: Array<{ amount: number; currency: string }>;
2411
+ }>;
2412
+ },
2413
+ Name
2414
+ >;
2415
+ get: FunctionReference<
2416
+ "query",
2417
+ "internal",
2418
+ { accountId: string; ownerKey: string },
2419
+ {
2420
+ _creationTime: number;
2421
+ _id: string;
2422
+ accountId: string;
2423
+ country: string;
2424
+ holdDeadline?: number;
2425
+ mode: "stripe_managed" | "custom";
2426
+ operationKey: string;
2427
+ ownerKey: string;
2428
+ previousSchedule?: {
2429
+ interval: "daily" | "weekly" | "monthly" | "manual";
2430
+ monthlyDays?: Array<number>;
2431
+ weeklyDays?: Array<string>;
2432
+ };
2433
+ revision: number;
2434
+ status: "changing" | "ready";
2435
+ thresholds: Array<{ amount: number; currency: string }>;
2436
+ } | null,
2437
+ Name
2438
+ >;
2439
+ operation: FunctionReference<
2440
+ "query",
2441
+ "internal",
2442
+ { accountId: string; operationKey: string; ownerKey: string },
2443
+ {
2444
+ _creationTime: number;
2445
+ _id: string;
2446
+ accountId: string;
2447
+ attempted?: boolean;
2448
+ country: string;
2449
+ expectedRevision: number;
2450
+ mode: "stripe_managed" | "custom";
2451
+ observedAt?: number;
2452
+ observedSchedule: {
2453
+ interval: "daily" | "weekly" | "monthly" | "manual";
2454
+ monthlyDays?: Array<number>;
2455
+ weeklyDays?: Array<string>;
2456
+ };
2457
+ operationKey: string;
2458
+ ownerKey: string;
2459
+ previousSchedule: {
2460
+ interval: "daily" | "weekly" | "monthly" | "manual";
2461
+ monthlyDays?: Array<number>;
2462
+ weeklyDays?: Array<string>;
2463
+ };
2464
+ revision: number;
2465
+ status: "pending" | "complete" | "canceled";
2466
+ targetSchedule: {
2467
+ interval: "daily" | "weekly" | "monthly" | "manual";
2468
+ monthlyDays?: Array<number>;
2469
+ weeklyDays?: Array<string>;
2470
+ };
2471
+ thresholds: Array<{ amount: number; currency: string }>;
2472
+ } | null,
2473
+ Name
2474
+ >;
2475
+ };
2476
+ };
2197
2477
  products: {
2198
2478
  mutations: {
2199
2479
  upsertPrice: FunctionReference<
@@ -3,6 +3,7 @@ import {
3
3
  getAccountPayouts as getAccountPayoutsLegacy,
4
4
  } from "../earnings/legacyReads.js";
5
5
  import { currencyTotals, previewSize } from "../earnings/reads.js";
6
+ import { addSummaryAmounts } from "../earnings/arithmetic.js";
6
7
  import { v } from "convex/values";
7
8
  import { paginator } from "convex-helpers/server/pagination";
8
9
  import schema from "../schema.js";
@@ -557,7 +558,7 @@ export const getAccountEarningsByCurrency = query({
557
558
  currency: r.currency,
558
559
  gross: r.gross,
559
560
  reversed: r.reversed,
560
- net: r.gross - r.reversed,
561
+ net: addSummaryAmounts(r.gross, -r.reversed),
561
562
  transferCount: r.count,
562
563
  })),
563
564
  transfers,
@@ -0,0 +1,10 @@
1
+ /** Check exact integer arithmetic before converting back to the public Number representation. */
2
+ export function addSummaryAmounts(left: number, right: number): number {
3
+ if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right))
4
+ throw new Error("Currency totals require safe integer amounts");
5
+ const result = BigInt(left) + BigInt(right);
6
+ const limit = BigInt(Number.MAX_SAFE_INTEGER);
7
+ if (result > limit || result < -limit)
8
+ throw new Error("Currency totals exceed supported safe integer bounds");
9
+ return Number(result);
10
+ }
@@ -1,4 +1,5 @@
1
1
  import type { QueryCtx } from "../_generated/server.js";
2
+ import { addSummaryAmounts } from "./arithmetic.js";
2
3
  export function previewSize(value = 50) {
3
4
  if (!Number.isInteger(value) || value < 0 || value > 100)
4
5
  throw new Error("Preview size must be an integer from 0 to 100");
@@ -37,5 +38,10 @@ export async function currencyTotals(
37
38
  .take(501);
38
39
  if (rows.length > 500)
39
40
  throw new Error("Account exceeds supported currency count");
41
+ for (const row of rows) {
42
+ for (const value of [row.gross, row.reversed, row.paidOut, row.count])
43
+ addSummaryAmounts(value, 0);
44
+ addSummaryAmounts(row.gross, -row.reversed);
45
+ }
40
46
  return rows;
41
47
  }
@@ -4,6 +4,7 @@ import {
4
4
  customMutation,
5
5
  } from "convex-helpers/server/customFunctions";
6
6
  import type { DataModel } from "../_generated/dataModel.js";
7
+ import { addSummaryAmounts } from "./arithmetic.js";
7
8
  import {
8
9
  mutation as rawMutation,
9
10
  type MutationCtx,
@@ -21,7 +22,7 @@ async function adjust(
21
22
  ctx: MutationCtx,
22
23
  kind: "earnings" | "payouts",
23
24
  value: Contribution,
24
- sign: number,
25
+ sign: 1 | -1,
25
26
  ) {
26
27
  const row = await ctx.db
27
28
  .query("currencyTotals")
@@ -33,11 +34,12 @@ async function adjust(
33
34
  )
34
35
  .unique();
35
36
  const totals = {
36
- gross: (row?.gross ?? 0) + sign * value.gross,
37
- reversed: (row?.reversed ?? 0) + sign * value.reversed,
38
- paidOut: (row?.paidOut ?? 0) + sign * value.paidOut,
39
- count: (row?.count ?? 0) + sign * value.count,
37
+ gross: addSummaryAmounts(row?.gross ?? 0, sign * value.gross),
38
+ reversed: addSummaryAmounts(row?.reversed ?? 0, sign * value.reversed),
39
+ paidOut: addSummaryAmounts(row?.paidOut ?? 0, sign * value.paidOut),
40
+ count: addSummaryAmounts(row?.count ?? 0, sign * value.count),
40
41
  };
42
+ addSummaryAmounts(totals.gross, -totals.reversed);
41
43
  if (row) {
42
44
  if (totals.count === 0) await ctx.db.delete("currencyTotals", row._id);
43
45
  else await ctx.db.patch("currencyTotals", row._id, totals);
@@ -72,14 +74,17 @@ triggers.register("transfers", async (ctx, change) => {
72
74
  triggers.register("payouts", async (ctx, change) => {
73
75
  const contribution = (
74
76
  row: NonNullable<typeof change.newDoc>,
75
- ): Contribution => ({
76
- accountId: row.accountId,
77
- currency: row.currency,
78
- gross: 0,
79
- reversed: 0,
80
- paidOut: row.status === "paid" ? row.amount : 0,
81
- count: 1,
82
- });
77
+ ): Contribution => {
78
+ const amount = addSummaryAmounts(row.amount, 0);
79
+ return {
80
+ accountId: row.accountId,
81
+ currency: row.currency,
82
+ gross: 0,
83
+ reversed: 0,
84
+ paidOut: row.status === "paid" ? amount : 0,
85
+ count: 1,
86
+ };
87
+ };
83
88
  if (change.oldDoc?.totalsRecorded)
84
89
  await adjust(ctx, "payouts", contribution(change.oldDoc), -1);
85
90
  if (change.newDoc) {
@@ -0,0 +1,248 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it } from "vitest";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ const m = (name: string) =>
8
+ makeFunctionReference<"mutation">(`payoutPolicy/mutations:${name}`);
9
+ const q = (name: string) =>
10
+ makeFunctionReference<"query">(`payoutPolicy/queries:${name}`);
11
+ const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
12
+ const initial = {
13
+ ...scope,
14
+ operationKey: "enable-1",
15
+ expectedRevision: -1,
16
+ mode: "custom",
17
+ thresholds: [{ currency: "usd", amount: 1000 }],
18
+ country: "US",
19
+ observedSchedule: { interval: "weekly", weeklyDays: ["friday", "monday"] },
20
+ };
21
+ it("persists restoration and a deadline before provider mutation, then confirms exactly once", async () => {
22
+ const t = convexTest(schema, modules);
23
+ const op = await t.mutation(m("prepare"), initial);
24
+ expect(op.status).toBe("pending");
25
+ expect(op.targetSchedule).toEqual({ interval: "manual" });
26
+ expect(op.previousSchedule).toEqual(initial.observedSchedule);
27
+ let policy = await t.query(q("get"), scope);
28
+ expect(policy.status).toBe("changing");
29
+ expect(policy.revision).toBe(0);
30
+ expect(policy.holdDeadline).toBeGreaterThan(Date.now());
31
+ await t.mutation(m("confirm"), {
32
+ ...scope,
33
+ operationKey: "enable-1",
34
+ observedSchedule: { interval: "manual" },
35
+ });
36
+ policy = await t.query(q("get"), scope);
37
+ expect(policy.status).toBe("ready");
38
+ expect(policy.mode).toBe("custom");
39
+ expect(
40
+ await t.mutation(m("confirm"), {
41
+ ...scope,
42
+ operationKey: "enable-1",
43
+ observedSchedule: { interval: "manual" },
44
+ }),
45
+ ).toEqual(
46
+ await t.query(q("operation"), { ...scope, operationKey: "enable-1" }),
47
+ );
48
+ });
49
+ it("preserves the original schedule and deadline across threshold changes and opt-out", async () => {
50
+ const t = convexTest(schema, modules);
51
+ await t.mutation(m("prepare"), initial);
52
+ await t.mutation(m("confirm"), {
53
+ ...scope,
54
+ operationKey: "enable-1",
55
+ observedSchedule: { interval: "manual" },
56
+ });
57
+ const original = await t.query(q("get"), scope);
58
+ const change = {
59
+ ...initial,
60
+ operationKey: "change",
61
+ expectedRevision: 0,
62
+ thresholds: [
63
+ { currency: "eur", amount: 500 },
64
+ { currency: "usd", amount: 2000 },
65
+ ],
66
+ observedSchedule: { interval: "manual" },
67
+ };
68
+ await t.mutation(m("prepare"), change);
69
+ await t.mutation(m("confirm"), {
70
+ ...scope,
71
+ operationKey: "change",
72
+ observedSchedule: { interval: "manual" },
73
+ });
74
+ const updated = await t.query(q("get"), scope);
75
+ expect(updated.previousSchedule).toEqual(original.previousSchedule);
76
+ expect(updated.holdDeadline).toBe(original.holdDeadline);
77
+ const off = await t.mutation(m("prepare"), {
78
+ ...change,
79
+ operationKey: "off",
80
+ expectedRevision: 1,
81
+ mode: "stripe_managed",
82
+ thresholds: [],
83
+ });
84
+ expect(off.targetSchedule).toEqual(initial.observedSchedule);
85
+ await t.mutation(m("confirm"), {
86
+ ...scope,
87
+ operationKey: "off",
88
+ observedSchedule: initial.observedSchedule,
89
+ });
90
+ expect(await t.query(q("get"), scope)).toMatchObject({
91
+ mode: "stripe_managed",
92
+ status: "ready",
93
+ thresholds: [],
94
+ });
95
+ });
96
+ it("rejects changed replays, ownership changes, stale revisions and competing operations", async () => {
97
+ const t = convexTest(schema, modules);
98
+ const op = await t.mutation(m("prepare"), initial);
99
+ expect(await t.mutation(m("prepare"), initial)).toEqual(op);
100
+ for (const changed of [
101
+ { thresholds: [{ currency: "usd", amount: 900 }] },
102
+ { ownerKey: "other" },
103
+ { accountId: "acct_other" },
104
+ ])
105
+ await expect(
106
+ t.mutation(m("prepare"), { ...initial, ...changed }),
107
+ ).rejects.toThrow(/identity|operation/);
108
+ await expect(
109
+ t.mutation(m("prepare"), {
110
+ ...initial,
111
+ operationKey: "other",
112
+ expectedRevision: 0,
113
+ }),
114
+ ).rejects.toThrow(/pending/);
115
+ await t.mutation(m("confirm"), {
116
+ ...scope,
117
+ operationKey: "enable-1",
118
+ observedSchedule: { interval: "manual" },
119
+ });
120
+ await expect(
121
+ t.mutation(m("prepare"), { ...initial, operationKey: "other" }),
122
+ ).rejects.toThrow(/revision/);
123
+ await expect(
124
+ t.query(q("get"), { ...scope, ownerKey: "other" }),
125
+ ).rejects.toThrow(/owner/);
126
+ });
127
+ it("cannot confirm a different provider schedule or hide external drift", async () => {
128
+ const t = convexTest(schema, modules);
129
+ await t.mutation(m("prepare"), initial);
130
+ await expect(
131
+ t.mutation(m("confirm"), {
132
+ ...scope,
133
+ operationKey: "enable-1",
134
+ observedSchedule: { interval: "daily" },
135
+ }),
136
+ ).rejects.toThrow(/schedule/);
137
+ await t.mutation(m("confirm"), {
138
+ ...scope,
139
+ operationKey: "enable-1",
140
+ observedSchedule: { interval: "manual" },
141
+ });
142
+ await expect(
143
+ t.mutation(m("prepare"), {
144
+ ...initial,
145
+ operationKey: "change",
146
+ expectedRevision: 0,
147
+ observedSchedule: { interval: "daily" },
148
+ }),
149
+ ).rejects.toThrow(/drift/);
150
+ });
151
+ it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])(
152
+ "rejects invalid custom minor-unit thresholds (%s)",
153
+ async (amount) => {
154
+ const t = convexTest(schema, modules);
155
+ await expect(
156
+ t.mutation(m("prepare"), {
157
+ ...initial,
158
+ thresholds: [{ currency: "usd", amount }],
159
+ }),
160
+ ).rejects.toThrow(/threshold/);
161
+ },
162
+ );
163
+ it("uses country holding limits and never extends a hold by editing thresholds", async () => {
164
+ for (const [country, days] of [
165
+ ["TH", 10],
166
+ ["US", 730],
167
+ ["GB", 90],
168
+ ] as const) {
169
+ const t = convexTest(schema, modules);
170
+ const before = Date.now();
171
+ await t.mutation(m("prepare"), { ...initial, country });
172
+ const policy = await t.query(q("get"), scope);
173
+ expect(policy.holdDeadline).toBeGreaterThanOrEqual(
174
+ before + days * 86400000,
175
+ );
176
+ expect(policy.holdDeadline).toBeLessThan(
177
+ Date.now() + days * 86400000 + 1000,
178
+ );
179
+ }
180
+ });
181
+ it("returns bounded due policies and retains expired changing operations for reconciliation", async () => {
182
+ const t = convexTest(schema, modules);
183
+ await t.mutation(m("prepare"), initial);
184
+ const p = await t.query(q("get"), scope);
185
+ const result = await t.query(q("due"), {
186
+ now: p.holdDeadline + 1,
187
+ paginationOpts: { numItems: 1, cursor: null },
188
+ });
189
+ expect(result.page).toHaveLength(1);
190
+ expect(result.page[0].status).toBe("changing");
191
+ });
192
+ it("accepts equivalent retries regardless of object property order", async () => {
193
+ const t = convexTest(schema, modules);
194
+ const op = await t.mutation(m("prepare"), initial);
195
+ expect(
196
+ await t.mutation(
197
+ m("prepare"),
198
+ Object.fromEntries(Object.entries(initial).reverse()),
199
+ ),
200
+ ).toEqual(op);
201
+ });
202
+ it("marks attempts durably and only cancels an unattempted provider change", async () => {
203
+ const t = convexTest(schema, modules);
204
+ await t.mutation(m("prepare"), initial);
205
+ const args = {
206
+ ...scope,
207
+ operationKey: "enable-1",
208
+ observedSchedule: initial.observedSchedule,
209
+ };
210
+ expect((await t.mutation(m("cancelUnattempted"), args)).status).toBe(
211
+ "canceled",
212
+ );
213
+ expect(await t.query(q("get"), scope)).toMatchObject({
214
+ mode: "stripe_managed",
215
+ status: "ready",
216
+ });
217
+ await expect(
218
+ t.mutation(m("confirm"), {
219
+ ...scope,
220
+ operationKey: "enable-1",
221
+ observedSchedule: { interval: "manual" },
222
+ }),
223
+ ).rejects.toThrow(/canceled/);
224
+ await t.mutation(m("prepare"), {
225
+ ...initial,
226
+ operationKey: "second",
227
+ expectedRevision: 0,
228
+ });
229
+ await t.mutation(m("beginAttempt"), { ...scope, operationKey: "second" });
230
+ await expect(
231
+ t.mutation(m("cancelUnattempted"), { ...args, operationKey: "second" }),
232
+ ).rejects.toThrow(/attempt/);
233
+ });
234
+ it("anchors the hold deadline before provider reads and rejects future observations", async () => {
235
+ const t = convexTest(schema, modules);
236
+ const observedAt = Date.now() - 60000;
237
+ await t.mutation(m("prepare"), { ...initial, observedAt });
238
+ expect((await t.query(q("get"), scope)).holdDeadline).toBe(
239
+ observedAt + 730 * 86400000,
240
+ );
241
+ await expect(
242
+ t.mutation(m("prepare"), {
243
+ ...initial,
244
+ operationKey: "future",
245
+ observedAt: Date.now() + 60000,
246
+ }),
247
+ ).rejects.toThrow(/observation/);
248
+ });