@fantastic.dev/better-stripe 0.25.0 → 0.26.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 (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +11 -0
  3. package/README.md +4 -1
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecution.js +8 -83
  6. package/dist/client/connect/payoutExecution.js.map +1 -1
  7. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
  8. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  9. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  10. package/dist/client/connect/payoutPreview.d.ts +20 -0
  11. package/dist/client/connect/payoutPreview.d.ts.map +1 -0
  12. package/dist/client/connect/payoutPreview.js +44 -0
  13. package/dist/client/connect/payoutPreview.js.map +1 -0
  14. package/dist/client/connect/payoutQualification.d.ts +30 -0
  15. package/dist/client/connect/payoutQualification.d.ts.map +1 -0
  16. package/dist/client/connect/payoutQualification.js +98 -0
  17. package/dist/client/connect/payoutQualification.js.map +1 -0
  18. package/dist/client/index.d.ts +0 -3
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +2 -4
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/client/payoutPolicy.d.ts +3 -0
  23. package/dist/client/payoutPolicy.d.ts.map +1 -1
  24. package/dist/client/payoutPolicy.js +4 -0
  25. package/dist/client/payoutPolicy.js.map +1 -1
  26. package/dist/client/readContracts.d.ts +3 -0
  27. package/dist/client/readContracts.d.ts.map +1 -1
  28. package/dist/client/readContracts.js +4 -0
  29. package/dist/client/readContracts.js.map +1 -1
  30. package/dist/client/setup.test.d.ts +18 -0
  31. package/dist/client/setup.test.d.ts.map +1 -0
  32. package/dist/client/setup.test.js +33 -0
  33. package/dist/client/setup.test.js.map +1 -0
  34. package/dist/client/types/triggers.d.ts +1 -1
  35. package/dist/client/types/triggers.d.ts.map +1 -1
  36. package/dist/client/webhooks/helpers.d.ts.map +1 -1
  37. package/dist/client/webhooks/helpers.js +1 -0
  38. package/dist/client/webhooks/helpers.js.map +1 -1
  39. package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
  40. package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
  41. package/dist/client/webhooks/payoutReconciliation.js +36 -0
  42. package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
  43. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
  44. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
  45. package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
  46. package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
  47. package/dist/client/webhooks/processors.d.ts.map +1 -1
  48. package/dist/client/webhooks/processors.js +1 -20
  49. package/dist/client/webhooks/processors.js.map +1 -1
  50. package/dist/component/_generated/component.d.ts +39 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -1
  52. package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
  53. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
  54. package/dist/component/affiliateRecovery/accounting.js.map +1 -1
  55. package/dist/component/payoutRelease/helpers.d.ts +4 -4
  56. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  57. package/dist/component/payoutRelease/helpers.js.map +1 -1
  58. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  59. package/dist/component/payoutRelease/mutations.js +2 -29
  60. package/dist/component/payoutRelease/mutations.js.map +1 -1
  61. package/dist/component/payoutRelease/planning.d.ts +27 -0
  62. package/dist/component/payoutRelease/planning.d.ts.map +1 -0
  63. package/dist/component/payoutRelease/planning.js +35 -0
  64. package/dist/component/payoutRelease/planning.js.map +1 -0
  65. package/dist/component/payoutRelease/preview.d.ts +117 -0
  66. package/dist/component/payoutRelease/preview.d.ts.map +1 -0
  67. package/dist/component/payoutRelease/preview.js +82 -0
  68. package/dist/component/payoutRelease/preview.js.map +1 -0
  69. package/dist/component/payoutRelease/queries.d.ts +39 -0
  70. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  71. package/dist/component/payoutRelease/queries.js +6 -0
  72. package/dist/component/payoutRelease/queries.js.map +1 -1
  73. package/docs/read-contracts.md +21 -5
  74. package/docs/settlement.md +57 -0
  75. package/package.json +1 -1
  76. package/src/client/connect/payoutExecution.ts +12 -99
  77. package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
  78. package/src/client/connect/payoutPreview.test.ts +269 -0
  79. package/src/client/connect/payoutPreview.ts +77 -0
  80. package/src/client/connect/payoutQualification.ts +145 -0
  81. package/src/client/index.ts +3 -6
  82. package/src/client/payoutPolicy.ts +14 -0
  83. package/src/client/readContracts.ts +5 -0
  84. package/src/client/types/triggers.ts +1 -0
  85. package/src/client/webhooks/helpers.ts +1 -0
  86. package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
  87. package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
  88. package/src/client/webhooks/payoutReconciliation.ts +72 -0
  89. package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
  90. package/src/client/webhooks/processors.test.ts +44 -29
  91. package/src/client/webhooks/processors.ts +1 -27
  92. package/src/component/_generated/api.ts +4 -0
  93. package/src/component/_generated/component.ts +42 -0
  94. package/src/component/affiliateRecovery/accounting.ts +2 -2
  95. package/src/component/payoutRelease/helpers.ts +7 -4
  96. package/src/component/payoutRelease/mutations.ts +2 -37
  97. package/src/component/payoutRelease/planning.ts +55 -0
  98. package/src/component/payoutRelease/preview.ts +118 -0
  99. package/src/component/payoutRelease/queries.ts +6 -0
@@ -96,6 +96,7 @@ type MockReversal = {
96
96
 
97
97
  function makeStripe() {
98
98
  return {
99
+ payouts: { retrieve: vi.fn() },
99
100
  refunds: {
100
101
  list: vi.fn(() =>
101
102
  (async function* () {
@@ -791,63 +792,77 @@ describe("processEvent — payments (status collapse)", () => {
791
792
  });
792
793
 
793
794
  describe("processEvent — payouts", () => {
794
- it("scopes connected payouts to the event account instead of the external bank", async () => {
795
+ function context(id: string, status = "paid") {
795
796
  const whCtx = makeWhCtx();
796
- const payoutEvent = event("payout.paid", {
797
- id: "po_connected",
798
- destination: "ba_bank",
797
+ whCtx.ctx.runMutation.mockResolvedValue(1 as never);
798
+ whCtx.stripe.payouts.retrieve.mockResolvedValue({
799
+ id,
799
800
  amount: 5000,
800
801
  currency: "usd",
801
- status: "paid",
802
+ status,
802
803
  arrival_date: 1700000000,
803
- metadata: {},
804
+ automatic: true,
805
+ method: "standard",
806
+ metadata: { accountId: "acct_untrusted" },
807
+ });
808
+ return whCtx;
809
+ }
810
+ it("scopes connected provider reads to the signed event account", async () => {
811
+ const whCtx = context("po_connected");
812
+ const payoutEvent = event("payout.paid", {
813
+ id: "po_connected",
814
+ destination: "ba_bank",
815
+ metadata: { accountId: "acct_untrusted" },
804
816
  });
805
817
  payoutEvent.account = "acct_store";
806
818
  await processEvent(whCtx, payoutEvent);
807
- expect(dispatchedPayload(whCtx.ctx).data.accountId).toBe("acct_store");
819
+ expect(whCtx.stripe.payouts.retrieve).toHaveBeenCalledWith(
820
+ "po_connected",
821
+ {},
822
+ { stripeAccount: "acct_store" },
823
+ );
824
+ expect(dispatchedPayload(whCtx.ctx, 1).data).toMatchObject({
825
+ accountId: "acct_store",
826
+ generation: 1,
827
+ payout: { status: "paid", accountId: "acct_store" },
828
+ });
808
829
  });
809
-
810
- it("normalizes a payout, taking destination as a string id", async () => {
811
- const whCtx = makeWhCtx();
830
+ it("uses explicit platform scope instead of a bank destination or metadata account", async () => {
831
+ const whCtx = context("po_1");
812
832
  await processEvent(
813
833
  whCtx,
814
834
  event("payout.paid", {
815
835
  id: "po_1",
816
836
  destination: "ba_123",
817
- amount: 5000,
818
- currency: "usd",
819
- status: "paid",
820
- arrival_date: 1700000000,
821
- method: "standard",
822
- metadata: {},
837
+ status: "pending",
838
+ metadata: { accountId: "acct_forged" },
823
839
  }),
824
840
  );
825
- const { path, data } = dispatchedPayload(whCtx.ctx);
826
- expect(path).toBe("betterStripe/connect/mutations/upsertPayout");
841
+ const { path, data } = dispatchedPayload(whCtx.ctx, 1);
842
+ expect(path).toBe("betterStripe/payoutSync/mutations/finish");
827
843
  expect(data).toMatchObject({
828
844
  stripePayoutId: "po_1",
829
- accountId: "ba_123",
830
- amount: 5000,
831
- status: "paid",
832
- method: "standard",
845
+ accountId: "platform",
846
+ generation: 1,
847
+ payout: { status: "paid", amount: 5000, accountId: "platform" },
833
848
  });
849
+ expect(whCtx.stripe.payouts.retrieve).toHaveBeenCalledWith("po_1", {}, {});
834
850
  });
835
-
836
- it("falls back to an empty-string accountId for an expanded destination without id", async () => {
837
- const whCtx = makeWhCtx();
851
+ it("ignores malformed stale event fields when the current platform payout is valid", async () => {
852
+ const whCtx = context("po_2", "failed");
838
853
  await processEvent(
839
854
  whCtx,
840
855
  event("payout.failed", {
841
856
  id: "po_2",
842
857
  destination: {},
843
- amount: 100,
844
- currency: "usd",
845
- status: "failed",
846
858
  arrival_date: null,
847
859
  metadata: {},
848
860
  }),
849
861
  );
850
- expect(dispatchedPayload(whCtx.ctx).data.accountId).toBe("");
862
+ expect(dispatchedPayload(whCtx.ctx, 1).data).toMatchObject({
863
+ accountId: "platform",
864
+ payout: { status: "failed", arrivalDate: "2023-11-14T22:13:20.000Z" },
865
+ });
851
866
  });
852
867
  });
853
868
 
@@ -1,3 +1,4 @@
1
+ import { handlePayoutEvent } from "./payoutReconciliation.js";
1
2
  import { reconcileDisputeMoney } from "./disputeRecovery.js";
2
3
  import { successfulRefundPrincipal } from "../connect/refundPrincipal.js";
3
4
  import { subscriptionRouting } from "../billing/subscriptionRouting.js";
@@ -900,33 +901,6 @@ async function upsertPaymentFromStripe(
900
901
  });
901
902
  }
902
903
 
903
- async function handlePayoutEvent(
904
- whCtx: WebhookContext,
905
- payout: Stripe.Payout,
906
- connectedAccountId?: string,
907
- ): Promise<void> {
908
- await dispatchUpsert(whCtx, "payoutUpserted", {
909
- stripePayoutId: payout.id,
910
- accountId:
911
- connectedAccountId ??
912
- (payout.metadata?.accountId?.startsWith("acct_")
913
- ? payout.metadata.accountId
914
- : undefined) ??
915
- (typeof payout.destination === "string"
916
- ? payout.destination
917
- : (payout.destination?.id ?? "")),
918
- amount: payout.amount,
919
- currency: payout.currency,
920
- status: payout.status as
921
- "pending" | "paid" | "failed" | "canceled" | "in_transit",
922
- arrivalDate: epochToIso(payout.arrival_date),
923
- method: payout.method ?? undefined,
924
- failureCode: payout.failure_code ?? undefined,
925
- failureMessage: payout.failure_message ?? undefined,
926
- metadata: payout.metadata ?? undefined,
927
- });
928
- }
929
-
930
904
  // Stripe types Refund.status as `string | null`; clamp to the documented union.
931
905
  const REFUND_STATUSES = new Set([
932
906
  "pending",
@@ -56,6 +56,8 @@ import type * as payoutRelease_fees from "../payoutRelease/fees.js";
56
56
  import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
57
57
  import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
58
58
  import type * as payoutRelease_observation from "../payoutRelease/observation.js";
59
+ import type * as payoutRelease_planning from "../payoutRelease/planning.js";
60
+ import type * as payoutRelease_preview from "../payoutRelease/preview.js";
59
61
  import type * as payoutRelease_queries from "../payoutRelease/queries.js";
60
62
  import type * as payoutSnapshots_localState from "../payoutSnapshots/localState.js";
61
63
  import type * as payoutSnapshots_mutations from "../payoutSnapshots/mutations.js";
@@ -136,6 +138,8 @@ const fullApi: ApiFromModules<{
136
138
  "payoutRelease/helpers": typeof payoutRelease_helpers;
137
139
  "payoutRelease/mutations": typeof payoutRelease_mutations;
138
140
  "payoutRelease/observation": typeof payoutRelease_observation;
141
+ "payoutRelease/planning": typeof payoutRelease_planning;
142
+ "payoutRelease/preview": typeof payoutRelease_preview;
139
143
  "payoutRelease/queries": typeof payoutRelease_queries;
140
144
  "payoutSnapshots/localState": typeof payoutSnapshots_localState;
141
145
  "payoutSnapshots/mutations": typeof payoutSnapshots_mutations;
@@ -3608,6 +3608,48 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3608
3608
  },
3609
3609
  Name
3610
3610
  >;
3611
+ preview: FunctionReference<
3612
+ "query",
3613
+ "internal",
3614
+ {
3615
+ accountId: string;
3616
+ currency: string;
3617
+ expectedRevision: number;
3618
+ independentLiabilities: number;
3619
+ observations: Array<{
3620
+ currency: string;
3621
+ ledgerFingerprint: string;
3622
+ matureNet: number;
3623
+ observedAt: number;
3624
+ pendingCredits: number;
3625
+ stripeTransferId: string;
3626
+ transactionIds: Array<string>;
3627
+ }>;
3628
+ ownerKey: string;
3629
+ providerAvailable: number;
3630
+ },
3631
+ | { amount: 0; reason: string; status: "held" }
3632
+ | {
3633
+ amount: number;
3634
+ confirmedFees: number;
3635
+ currency: string;
3636
+ independentLiabilities: number;
3637
+ matureUnpaid: number;
3638
+ observedAt: number;
3639
+ pendingCredits: number;
3640
+ providerAvailable: number;
3641
+ providerMinimum: null;
3642
+ reason?: string;
3643
+ sources: Array<{
3644
+ matureUnpaid: number;
3645
+ pendingCredits: number;
3646
+ role: "store" | "affiliate" | "other";
3647
+ }>;
3648
+ status: "held" | "qualified";
3649
+ threshold: number;
3650
+ },
3651
+ Name
3652
+ >;
3611
3653
  snapshot: FunctionReference<
3612
3654
  "query",
3613
3655
  "internal",
@@ -1,5 +1,5 @@
1
1
  import type { Doc } from "../_generated/dataModel.js";
2
- import type { MutationCtx } from "../_generated/server.js";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
3
  export type Obligation = Omit<
4
4
  Doc<"affiliateObligations">,
5
5
  "_id" | "_creationTime"
@@ -13,7 +13,7 @@ export const due = (row: Obligation) =>
13
13
  export const credit = (row: Obligation) =>
14
14
  row.needsReconciliation ? 0 : Math.max(0, netRecovery(row) - target(row));
15
15
  export async function balance(
16
- ctx: MutationCtx,
16
+ ctx: MutationCtx | QueryCtx,
17
17
  account: string,
18
18
  currency: string,
19
19
  ) {
@@ -1,6 +1,6 @@
1
1
  import type { Infer } from "convex/values";
2
2
  import type { transferDocValidator } from "../connect/validators.js";
3
- import type { MutationCtx } from "../_generated/server.js";
3
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
4
4
  import type { Doc } from "../_generated/dataModel.js";
5
5
  import { readPolicy, validateScope } from "../payoutPolicy/helpers.js";
6
6
  import type { observation } from "./schema.js";
@@ -50,7 +50,7 @@ export async function claimByKey(
50
50
  return claim;
51
51
  }
52
52
  export async function readyPolicy(
53
- ctx: MutationCtx,
53
+ ctx: MutationCtx | QueryCtx,
54
54
  args: { accountId: string; ownerKey: string; expectedRevision: number },
55
55
  ) {
56
56
  const policy = await readPolicy(ctx, args);
@@ -67,7 +67,7 @@ export async function readyPolicy(
67
67
  }
68
68
  /** Full local account membership is checked under OCC before any reservation. */
69
69
  export async function checkSnapshot(
70
- ctx: MutationCtx,
70
+ ctx: MutationCtx | QueryCtx,
71
71
  accountId: string,
72
72
  observations: Infer<typeof observation>[],
73
73
  fresh: boolean,
@@ -126,7 +126,10 @@ export async function checkSnapshot(
126
126
  }
127
127
  return byId;
128
128
  }
129
- export async function sourceById(ctx: MutationCtx, stripeTransferId: string) {
129
+ export async function sourceById(
130
+ ctx: MutationCtx | QueryCtx,
131
+ stripeTransferId: string,
132
+ ) {
130
133
  return ctx.db
131
134
  .query("payoutSourceAllocations")
132
135
  .withIndex("by_transfer", (q) => q.eq("stripeTransferId", stripeTransferId))
@@ -19,7 +19,7 @@ import {
19
19
  validateIdentity,
20
20
  add,
21
21
  } from "./helpers.js";
22
- import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
22
+ import { readPayoutPlan } from "./planning.js";
23
23
  const held = v.object({
24
24
  status: v.literal("held"),
25
25
  reason: v.union(
@@ -63,10 +63,6 @@ export const claim = mutation({
63
63
  return existing;
64
64
  }
65
65
  const policy = await readyPolicy(ctx, args);
66
- const minimum = policy.thresholds.find(
67
- (value) => value.currency === args.currency,
68
- );
69
- if (!minimum) throw new Error("Payout policy currency is not configured.");
70
66
  const pending = await ctx.db
71
67
  .query("payoutReleaseClaims")
72
68
  .withIndex("by_account_status", (q) =>
@@ -81,38 +77,7 @@ export const claim = mutation({
81
77
  currency: args.currency,
82
78
  providerMinimum: null,
83
79
  };
84
- const rows = await checkSnapshot(
85
- ctx,
86
- args.accountId,
87
- args.observations,
88
- true,
89
- );
90
- const sources: PayoutFundingSource[] = [];
91
- for (const observation of args.observations) {
92
- const prior = await sourceById(ctx, observation.stripeTransferId);
93
- if (
94
- prior &&
95
- (prior.accountId !== args.accountId ||
96
- prior.ownerKey !== args.ownerKey ||
97
- prior.currency !== observation.currency)
98
- )
99
- throw new Error("Payout source allocation identity changed.");
100
- sources.push({
101
- ...observation,
102
- role: rows.get(observation.stripeTransferId)!.role,
103
- paidAmount: prior?.paidAmount ?? 0,
104
- reservedAmount: prior?.reservedAmount ?? 0,
105
- });
106
- }
107
- const fees = await readFees(ctx, args);
108
- const plan = planRecipientPayout({
109
- currency: args.currency,
110
- threshold: minimum.amount,
111
- providerAvailable: args.providerAvailable,
112
- independentLiabilities: args.independentLiabilities,
113
- confirmedFees: fees?.amount ?? 0,
114
- sources,
115
- });
80
+ const { plan, fees } = await readPayoutPlan(ctx, args, policy);
116
81
  if (plan.status === "held") return plan;
117
82
  await reserveRecoveryGuard(ctx, args, plan.amount);
118
83
  for (const allocation of plan.allocations) {
@@ -0,0 +1,55 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
+ import type { policyDoc } from "../payoutPolicy/schema.js";
4
+ import type { observation } from "./observation.js";
5
+ import { checkSnapshot, sourceById } from "./helpers.js";
6
+ import { readFees } from "./fees.js";
7
+ import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
8
+ export async function readPayoutPlan(
9
+ ctx: MutationCtx | QueryCtx,
10
+ args: {
11
+ accountId: string;
12
+ ownerKey: string;
13
+ currency: string;
14
+ observations: Infer<typeof observation>[];
15
+ providerAvailable: number;
16
+ independentLiabilities: number;
17
+ },
18
+ policy: Infer<typeof policyDoc>,
19
+ ) {
20
+ const minimum = policy.thresholds.find((t) => t.currency === args.currency);
21
+ if (!minimum) throw new Error("Payout policy currency is not configured.");
22
+ const rows = await checkSnapshot(
23
+ ctx,
24
+ args.accountId,
25
+ args.observations,
26
+ true,
27
+ );
28
+ const sources: PayoutFundingSource[] = [];
29
+ for (const observation of args.observations) {
30
+ const prior = await sourceById(ctx, observation.stripeTransferId);
31
+ if (
32
+ prior &&
33
+ (prior.accountId !== args.accountId ||
34
+ prior.ownerKey !== args.ownerKey ||
35
+ prior.currency !== observation.currency)
36
+ )
37
+ throw new Error("Payout source allocation identity changed.");
38
+ sources.push({
39
+ ...observation,
40
+ role: rows.get(observation.stripeTransferId)!.role,
41
+ paidAmount: prior?.paidAmount ?? 0,
42
+ reservedAmount: prior?.reservedAmount ?? 0,
43
+ });
44
+ }
45
+ const fees = await readFees(ctx, args);
46
+ const plan = planRecipientPayout({
47
+ currency: args.currency,
48
+ threshold: minimum.amount,
49
+ providerAvailable: args.providerAvailable,
50
+ independentLiabilities: args.independentLiabilities,
51
+ confirmedFees: fees?.amount ?? 0,
52
+ sources,
53
+ });
54
+ return { plan, sources, fees, threshold: minimum.amount };
55
+ }
@@ -0,0 +1,118 @@
1
+ import { v } from "convex/values";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
+ import type { Infer } from "convex/values";
4
+ import { scopeFields } from "../payoutPolicy/schema.js";
5
+ import { observation } from "./observation.js";
6
+ import { readyPolicy, add } from "./helpers.js";
7
+ import { readPayoutPlan } from "./planning.js";
8
+ import { balance } from "../affiliateRecovery/accounting.js";
9
+ export const previewFields = {
10
+ ...scopeFields,
11
+ expectedRevision: v.number(),
12
+ currency: v.string(),
13
+ providerAvailable: v.number(),
14
+ independentLiabilities: v.number(),
15
+ observations: v.array(observation),
16
+ };
17
+ const held = v.object({
18
+ status: v.literal("held"),
19
+ reason: v.string(),
20
+ amount: v.literal(0),
21
+ });
22
+ export const previewResult = v.union(
23
+ held,
24
+ v.object({
25
+ status: v.union(v.literal("held"), v.literal("qualified")),
26
+ reason: v.optional(v.string()),
27
+ currency: v.string(),
28
+ amount: v.number(),
29
+ threshold: v.number(),
30
+ providerMinimum: v.null(),
31
+ matureUnpaid: v.number(),
32
+ pendingCredits: v.number(),
33
+ confirmedFees: v.number(),
34
+ independentLiabilities: v.number(),
35
+ providerAvailable: v.number(),
36
+ observedAt: v.number(),
37
+ sources: v.array(
38
+ v.object({
39
+ role: v.union(
40
+ v.literal("store"),
41
+ v.literal("affiliate"),
42
+ v.literal("other"),
43
+ ),
44
+ matureUnpaid: v.number(),
45
+ pendingCredits: v.number(),
46
+ }),
47
+ ),
48
+ }),
49
+ );
50
+ type Args = {
51
+ [K in keyof typeof previewFields]: Infer<(typeof previewFields)[K]>;
52
+ };
53
+ /** Trusted provider observations, informational only. No reservation or monetary state change. */
54
+ export async function previewPlan(
55
+ ctx: MutationCtx | QueryCtx,
56
+ args: Args,
57
+ ): Promise<Infer<typeof previewResult>> {
58
+ const policy = await readyPolicy(ctx, args);
59
+ const recovery = await balance(ctx, args.accountId, args.currency);
60
+ if (
61
+ recovery &&
62
+ (recovery.outstandingDebt > 0 ||
63
+ recovery.reservedRecovery > 0 ||
64
+ recovery.reservedRestoration > 0 ||
65
+ recovery.activePayoutKey)
66
+ )
67
+ return { status: "held", reason: "affiliate_recovery_pending", amount: 0 };
68
+ const pending = await ctx.db
69
+ .query("payoutReleaseClaims")
70
+ .withIndex("by_account_status", (q) =>
71
+ q.eq("accountId", args.accountId).eq("status", "reserved"),
72
+ )
73
+ .first();
74
+ if (pending) return { status: "held", reason: "pending_claim", amount: 0 };
75
+ const {
76
+ plan,
77
+ sources: funding,
78
+ fees,
79
+ threshold,
80
+ } = await readPayoutPlan(ctx, args, policy);
81
+ const sources = (["store", "affiliate", "other"] as const).map((role) => {
82
+ let matureUnpaid = 0,
83
+ pendingCredits = 0;
84
+ for (const source of funding)
85
+ if (
86
+ source.currency === args.currency &&
87
+ (source.role ?? "other") === role
88
+ ) {
89
+ matureUnpaid = add(
90
+ matureUnpaid,
91
+ add(
92
+ add(source.matureNet, -source.paidAmount),
93
+ -source.reservedAmount,
94
+ ),
95
+ );
96
+ pendingCredits = add(pendingCredits, source.pendingCredits);
97
+ }
98
+ return { role, matureUnpaid, pendingCredits };
99
+ });
100
+ return {
101
+ status: plan.status,
102
+ ...(plan.status === "held" ? { reason: plan.reason } : {}),
103
+ currency: plan.currency,
104
+ amount: plan.amount,
105
+ threshold,
106
+ providerMinimum: null,
107
+ matureUnpaid: plan.matureUnpaid,
108
+ pendingCredits: plan.pendingCredits,
109
+ confirmedFees: fees?.amount ?? 0,
110
+ independentLiabilities: args.independentLiabilities,
111
+ providerAvailable: args.providerAvailable,
112
+ observedAt: Math.min(
113
+ Date.now(),
114
+ ...args.observations.map((o) => o.observedAt),
115
+ ),
116
+ sources,
117
+ };
118
+ }
@@ -1,3 +1,4 @@
1
+ import { previewFields, previewResult, previewPlan } from "./preview.js";
1
2
  import { paginator } from "convex-helpers/server/pagination";
2
3
  import schema from "../schema.js";
3
4
  import { transferDocValidator } from "../connect/validators.js";
@@ -8,6 +9,11 @@ import { query } from "../_generated/server.js";
8
9
  import { identity, claimDoc } from "./schema.js";
9
10
  import { validateIdentity } from "./helpers.js";
10
11
  import { readPolicy } from "../payoutPolicy/helpers.js";
12
+ export const preview = query({
13
+ args: previewFields,
14
+ returns: previewResult,
15
+ handler: previewPlan,
16
+ });
11
17
  export const get = query({
12
18
  args: identity,
13
19
  returns: v.union(claimDoc, v.null()),