@fantastic.dev/better-stripe 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -1
  3. package/dist/client/billing/checkout.d.ts +3 -43
  4. package/dist/client/billing/checkout.d.ts.map +1 -1
  5. package/dist/client/billing/checkout.js +5 -260
  6. package/dist/client/billing/checkout.js.map +1 -1
  7. package/dist/client/billing/checkoutRequest.d.ts +129 -0
  8. package/dist/client/billing/checkoutRequest.d.ts.map +1 -0
  9. package/dist/client/billing/checkoutRequest.js +263 -0
  10. package/dist/client/billing/checkoutRequest.js.map +1 -0
  11. package/dist/client/billing/preparedCheckout.d.ts +22 -0
  12. package/dist/client/billing/preparedCheckout.d.ts.map +1 -0
  13. package/dist/client/billing/preparedCheckout.js +86 -0
  14. package/dist/client/billing/preparedCheckout.js.map +1 -0
  15. package/dist/client/checkoutRequests.d.ts +80 -0
  16. package/dist/client/checkoutRequests.d.ts.map +1 -0
  17. package/dist/client/checkoutRequests.js +28 -0
  18. package/dist/client/checkoutRequests.js.map +1 -0
  19. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +464 -0
  20. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  21. package/dist/client/connect/payoutCollection.d.ts +21 -0
  22. package/dist/client/connect/payoutCollection.d.ts.map +1 -0
  23. package/dist/client/connect/payoutCollection.js +47 -0
  24. package/dist/client/connect/payoutCollection.js.map +1 -0
  25. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +1805 -0
  26. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -0
  27. package/dist/client/connect/payoutExecutionTestHelpers.js +194 -0
  28. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -0
  29. package/dist/client/connect/payoutSyncTestHelpers.d.ts +232 -0
  30. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  31. package/dist/client/connect/payoutWorker.d.ts +48 -0
  32. package/dist/client/connect/payoutWorker.d.ts.map +1 -0
  33. package/dist/client/connect/payoutWorker.js +78 -0
  34. package/dist/client/connect/payoutWorker.js.map +1 -0
  35. package/dist/client/index.d.ts +4 -14
  36. package/dist/client/index.d.ts.map +1 -1
  37. package/dist/client/index.js +6 -13
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/client/payoutPolicy.d.ts +67 -0
  40. package/dist/client/payoutPolicy.d.ts.map +1 -1
  41. package/dist/client/payoutPolicy.js +10 -0
  42. package/dist/client/payoutPolicy.js.map +1 -1
  43. package/dist/client/readContracts.d.ts +14 -1
  44. package/dist/client/readContracts.d.ts.map +1 -1
  45. package/dist/client/readContracts.js +14 -0
  46. package/dist/client/readContracts.js.map +1 -1
  47. package/dist/client/setup.test.d.ts +18 -0
  48. package/dist/client/setup.test.d.ts.map +1 -0
  49. package/dist/client/setup.test.js +33 -0
  50. package/dist/client/setup.test.js.map +1 -0
  51. package/dist/client/types/triggers.d.ts +1 -1
  52. package/dist/client/types/triggers.d.ts.map +1 -1
  53. package/dist/client/webhooks/helpers.d.ts.map +1 -1
  54. package/dist/client/webhooks/helpers.js +1 -0
  55. package/dist/client/webhooks/helpers.js.map +1 -1
  56. package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
  57. package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
  58. package/dist/client/webhooks/payoutReconciliation.js +36 -0
  59. package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
  60. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
  61. package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
  62. package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
  63. package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
  64. package/dist/client/webhooks/processors.d.ts.map +1 -1
  65. package/dist/client/webhooks/processors.js +1 -20
  66. package/dist/client/webhooks/processors.js.map +1 -1
  67. package/dist/component/_generated/component.d.ts +293 -0
  68. package/dist/component/_generated/component.d.ts.map +1 -1
  69. package/dist/component/core/mutations.d.ts.map +1 -1
  70. package/dist/component/core/mutations.js +2 -0
  71. package/dist/component/core/mutations.js.map +1 -1
  72. package/dist/component/core/resetSafety.d.ts +4 -0
  73. package/dist/component/core/resetSafety.d.ts.map +1 -0
  74. package/dist/component/core/resetSafety.js +54 -0
  75. package/dist/component/core/resetSafety.js.map +1 -0
  76. package/dist/component/earnings/triggers.d.ts +418 -0
  77. package/dist/component/earnings/triggers.d.ts.map +1 -1
  78. package/dist/component/earnings/triggers.js +2 -0
  79. package/dist/component/earnings/triggers.js.map +1 -1
  80. package/dist/component/payoutPolicy/schema.d.ts +2 -0
  81. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  82. package/dist/component/payoutPolicy/schema.js +3 -1
  83. package/dist/component/payoutPolicy/schema.js.map +1 -1
  84. package/dist/component/payoutRelease/mutations.js +1 -1
  85. package/dist/component/payoutRelease/schema.d.ts +1 -0
  86. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  87. package/dist/component/payoutRelease/schema.js +1 -0
  88. package/dist/component/payoutRelease/schema.js.map +1 -1
  89. package/dist/component/payoutSnapshots/localState.d.ts +89 -0
  90. package/dist/component/payoutSnapshots/localState.d.ts.map +1 -0
  91. package/dist/component/payoutSnapshots/localState.js +186 -0
  92. package/dist/component/payoutSnapshots/localState.js.map +1 -0
  93. package/dist/component/payoutSnapshots/mutations.d.ts +108 -0
  94. package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -0
  95. package/dist/component/payoutSnapshots/mutations.js +263 -0
  96. package/dist/component/payoutSnapshots/mutations.js.map +1 -0
  97. package/dist/component/payoutSnapshots/revisions.d.ts +12 -0
  98. package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -0
  99. package/dist/component/payoutSnapshots/revisions.js +173 -0
  100. package/dist/component/payoutSnapshots/revisions.js.map +1 -0
  101. package/dist/component/payoutSnapshots/schema.d.ts +285 -0
  102. package/dist/component/payoutSnapshots/schema.d.ts.map +1 -0
  103. package/dist/component/payoutSnapshots/schema.js +108 -0
  104. package/dist/component/payoutSnapshots/schema.js.map +1 -0
  105. package/dist/component/payoutSnapshots/scope.d.ts +7 -0
  106. package/dist/component/payoutSnapshots/scope.d.ts.map +1 -0
  107. package/dist/component/payoutSnapshots/scope.js +8 -0
  108. package/dist/component/payoutSnapshots/scope.js.map +1 -0
  109. package/dist/component/payoutWorker/helpers.d.ts +30 -0
  110. package/dist/component/payoutWorker/helpers.d.ts.map +1 -0
  111. package/dist/component/payoutWorker/helpers.js +21 -0
  112. package/dist/component/payoutWorker/helpers.js.map +1 -0
  113. package/dist/component/payoutWorker/mutations.d.ts +68 -0
  114. package/dist/component/payoutWorker/mutations.d.ts.map +1 -0
  115. package/dist/component/payoutWorker/mutations.js +161 -0
  116. package/dist/component/payoutWorker/mutations.js.map +1 -0
  117. package/dist/component/payoutWorker/queries.d.ts +123 -0
  118. package/dist/component/payoutWorker/queries.d.ts.map +1 -0
  119. package/dist/component/payoutWorker/queries.js +61 -0
  120. package/dist/component/payoutWorker/queries.js.map +1 -0
  121. package/dist/component/payoutWorker/schema.d.ts +91 -0
  122. package/dist/component/payoutWorker/schema.d.ts.map +1 -0
  123. package/dist/component/payoutWorker/schema.js +27 -0
  124. package/dist/component/payoutWorker/schema.js.map +1 -0
  125. package/dist/component/schema.d.ts +232 -0
  126. package/dist/component/schema.js +10 -0
  127. package/dist/component/schema.js.map +1 -1
  128. package/docs/read-contracts.md +69 -5
  129. package/docs/settlement.md +128 -1
  130. package/package.json +1 -1
  131. package/src/client/billing/checkout.ts +11 -359
  132. package/src/client/billing/checkoutRequest.ts +362 -0
  133. package/src/client/billing/prepared-checkout.test.ts +430 -0
  134. package/src/client/billing/preparedCheckout.ts +143 -0
  135. package/src/client/checkoutRequests.ts +40 -0
  136. package/src/client/connect/payoutCollection.test.ts +74 -0
  137. package/src/client/connect/payoutCollection.ts +82 -0
  138. package/src/client/connect/payoutExecution.test.ts +1 -211
  139. package/src/client/connect/payoutExecutionTestHelpers.ts +213 -0
  140. package/src/client/connect/payoutWorker.test.ts +145 -0
  141. package/src/client/connect/payoutWorker.ts +144 -0
  142. package/src/client/index.ts +13 -27
  143. package/src/client/payoutPolicy.ts +26 -1
  144. package/src/client/readContracts.ts +28 -0
  145. package/src/client/types/triggers.ts +1 -0
  146. package/src/client/webhooks/helpers.ts +1 -0
  147. package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
  148. package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
  149. package/src/client/webhooks/payoutReconciliation.ts +72 -0
  150. package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
  151. package/src/client/webhooks/processors.test.ts +44 -29
  152. package/src/client/webhooks/processors.ts +1 -27
  153. package/src/component/_generated/api.ts +16 -0
  154. package/src/component/_generated/component.ts +361 -0
  155. package/src/component/core/mutations.ts +2 -0
  156. package/src/component/core/resetSafety.ts +67 -0
  157. package/src/component/earnings/triggers.ts +2 -0
  158. package/src/component/payout-reset-safety.test.ts +148 -0
  159. package/src/component/payout-revisions.test.ts +125 -0
  160. package/src/component/payout-snapshots.test.ts +395 -0
  161. package/src/component/payout-worker.test.ts +165 -0
  162. package/src/component/payoutPolicy/schema.ts +3 -1
  163. package/src/component/payoutRelease/mutations.ts +1 -1
  164. package/src/component/payoutRelease/schema.ts +1 -0
  165. package/src/component/payoutSnapshots/localState.ts +238 -0
  166. package/src/component/payoutSnapshots/mutations.ts +275 -0
  167. package/src/component/payoutSnapshots/revisions.ts +247 -0
  168. package/src/component/payoutSnapshots/schema.ts +125 -0
  169. package/src/component/payoutSnapshots/scope.ts +7 -0
  170. package/src/component/payoutWorker/helpers.ts +30 -0
  171. package/src/component/payoutWorker/mutations.ts +181 -0
  172. package/src/component/payoutWorker/queries.ts +64 -0
  173. package/src/component/payoutWorker/schema.ts +26 -0
  174. package/src/component/public.test.ts +2 -2
  175. package/src/component/schema.ts +18 -0
@@ -0,0 +1,144 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { jobDoc } from "../../component/payoutWorker/schema.js";
3
+ import type { policyDoc } from "../../component/payoutPolicy/schema.js";
4
+ import type { Component, RunCtx } from "../helpers.js";
5
+ import { runMutationOrThrow } from "../helpers.js";
6
+ import { componentRef } from "../webhooks/helpers.js";
7
+ import type {
8
+ PayoutReleaseOptions,
9
+ PayoutReleaseResult,
10
+ } from "./payoutExecution.js";
11
+ import type {
12
+ PayoutReconciliationOptions,
13
+ PayoutReconciliation,
14
+ } from "./payoutReconciliation.js";
15
+ export type PayoutWorkerJob = Infer<typeof jobDoc>;
16
+ export type PayoutWorkerScope = {
17
+ accountId: string;
18
+ ownerKey: string;
19
+ currency: string;
20
+ };
21
+ export type PayoutWorkerOptions = Omit<PayoutReleaseOptions, "operationKey"> & {
22
+ cadenceMs?: number;
23
+ };
24
+ type PageOptions = { cursor: string | null; numItems: number };
25
+ type Page<T> = { page: T[]; continueCursor: string; isDone: boolean };
26
+ type Held = { status: "held"; reason: string; nextAttemptAt?: number };
27
+ export type PayoutWorkerResult =
28
+ | Held
29
+ | { status: "completed"; job: PayoutWorkerJob; payout: PayoutReleaseResult };
30
+ /** Host cron only. No method on this worker represents a customer withdrawal request. */
31
+ export class PayoutWorkerClient {
32
+ constructor(
33
+ private readonly component: Component,
34
+ private readonly release: (
35
+ ctx: RunCtx,
36
+ opts: PayoutReleaseOptions,
37
+ ) => Promise<PayoutReleaseResult>,
38
+ private readonly reconcile: (
39
+ ctx: RunCtx,
40
+ opts: PayoutReconciliationOptions,
41
+ ) => Promise<PayoutReconciliation>,
42
+ ) {}
43
+ get(ctx: RunCtx, scope: PayoutWorkerScope): Promise<PayoutWorkerJob | null> {
44
+ return ctx.runQuery(
45
+ componentRef(this.component, "payoutWorker/queries/get"),
46
+ {
47
+ accountId: scope.accountId,
48
+ ownerKey: scope.ownerKey,
49
+ currency: scope.currency,
50
+ },
51
+ );
52
+ }
53
+ policies(
54
+ ctx: RunCtx,
55
+ paginationOpts: PageOptions,
56
+ ): Promise<Page<Infer<typeof policyDoc>>> {
57
+ return ctx.runQuery(
58
+ componentRef(this.component, "payoutWorker/queries/policies"),
59
+ { paginationOpts },
60
+ );
61
+ }
62
+ recovering(
63
+ ctx: RunCtx,
64
+ paginationOpts: PageOptions,
65
+ ): Promise<Page<PayoutWorkerJob>> {
66
+ return ctx.runQuery(
67
+ componentRef(this.component, "payoutWorker/queries/recovering"),
68
+ { paginationOpts },
69
+ );
70
+ }
71
+ changing(
72
+ ctx: RunCtx,
73
+ paginationOpts: PageOptions,
74
+ ): Promise<Page<Infer<typeof policyDoc>>> {
75
+ return ctx.runQuery(
76
+ componentRef(this.component, "payoutWorker/queries/changing"),
77
+ { paginationOpts },
78
+ );
79
+ }
80
+ async run(
81
+ ctx: RunCtx,
82
+ opts: PayoutWorkerOptions,
83
+ ): Promise<PayoutWorkerResult> {
84
+ if (
85
+ !Number.isSafeInteger(opts.independentLiabilities) ||
86
+ opts.independentLiabilities < 0 ||
87
+ (opts.maxPages !== undefined &&
88
+ (!Number.isInteger(opts.maxPages) ||
89
+ opts.maxPages < 1 ||
90
+ opts.maxPages > 100))
91
+ )
92
+ throw new Error("Invalid automatic payout liabilities or page budget.");
93
+ const scope = {
94
+ accountId: opts.accountId,
95
+ ownerKey: opts.ownerKey,
96
+ currency: opts.currency,
97
+ };
98
+ const started = (await runMutationOrThrow(
99
+ ctx,
100
+ componentRef(this.component, "payoutWorker/mutations/begin"),
101
+ {
102
+ ...scope,
103
+ cadenceMs: opts.cadenceMs ?? 300000,
104
+ },
105
+ )) as
106
+ Held | { status: "run"; job: PayoutWorkerJob; reconcileOnly: boolean };
107
+ if (started.status === "held") return started;
108
+ const operationKey = started.job.operationKey!;
109
+ const finish = (reason: string) =>
110
+ runMutationOrThrow(
111
+ ctx,
112
+ componentRef(this.component, "payoutWorker/mutations/finish"),
113
+ { ...scope, operationKey, generation: started.job.generation, reason },
114
+ ) as Promise<PayoutWorkerJob>;
115
+ let payout: PayoutReleaseResult;
116
+ try {
117
+ payout = started.reconcileOnly
118
+ ? await this.reconcile(ctx, {
119
+ ...scope,
120
+ operationKey,
121
+ maxPages: opts.maxPages,
122
+ })
123
+ : await this.release(ctx, {
124
+ ...scope,
125
+ operationKey,
126
+ independentLiabilities: opts.independentLiabilities,
127
+ maxPages: opts.maxPages,
128
+ });
129
+ } catch (error) {
130
+ try {
131
+ await finish("worker_error");
132
+ } catch (completionError) {
133
+ throw new AggregateError(
134
+ [error, completionError],
135
+ "Payout worker outcome requires reconciliation.",
136
+ );
137
+ }
138
+ throw error;
139
+ }
140
+ const reason =
141
+ payout.status === "held" ? payout.reason : payout.claim.status;
142
+ return { status: "completed", job: await finish(reason), payout };
143
+ }
144
+ }
@@ -1,5 +1,8 @@
1
+ import { reconcilePayoutAndTrigger } from "./webhooks/payoutReconciliation.js";
1
2
  import { AffiliateRecoveryClient } from "./affiliateRecovery.js";
2
3
  export * from "./affiliateRecovery.js";
4
+ import { CheckoutRequestClient } from "./checkoutRequests.js";
5
+ export type { PreparedCheckoutSession } from "./billing/preparedCheckout.js";
3
6
  import * as payoutSyncImpl from "./connect/payoutSync.js";
4
7
  export type {
5
8
  PayoutSyncOptions,
@@ -680,6 +683,14 @@ export class BetterStripe extends ReadContractClient {
680
683
  }
681
684
 
682
685
  // CHECKOUT METHODS
686
+ get checkoutRequests() {
687
+ return new CheckoutRequestClient(
688
+ this.component,
689
+ () => this.stripe(),
690
+ (fee) => this.resolveFee(fee),
691
+ this._statementDescriptorSuffix,
692
+ );
693
+ }
683
694
 
684
695
  async createCheckoutSession(ctx: RunCtx, opts: CreateCheckoutSessionOptions) {
685
696
  const { fee, ...rest } = opts;
@@ -699,27 +710,6 @@ export class BetterStripe extends ReadContractClient {
699
710
  );
700
711
  }
701
712
 
702
- async getCheckoutSession(
703
- ctx: RunCtx,
704
- opts: { sessionId: string },
705
- ): Promise<StripeComponentCheckoutSession | null> {
706
- return checkoutImpl.getCheckoutSession(this.component, ctx, opts);
707
- }
708
-
709
- async getCheckoutSessionByStripeId(
710
- ctx: RunCtx,
711
- opts: { stripeSessionId: string },
712
- ): Promise<StripeComponentCheckoutSession | null> {
713
- return checkoutImpl.getCheckoutSessionByStripeId(this.component, ctx, opts);
714
- }
715
-
716
- async listCheckoutSessionsByUser(
717
- ctx: RunCtx,
718
- opts: { userId: string; status?: string },
719
- ): Promise<StripeComponentCheckoutSession[]> {
720
- return checkoutImpl.listCheckoutSessionsByUser(this.component, ctx, opts);
721
- }
722
-
723
713
  async upsertCheckoutSession(
724
714
  ctx: RunCtx,
725
715
  opts: {
@@ -945,12 +935,6 @@ export class BetterStripe extends ReadContractClient {
945
935
  return subscriptionsImpl.upsertSubscription(this.component, ctx, opts);
946
936
  }
947
937
 
948
- async getTrialStatus(ctx: RunCtx, opts: { subscriptionId: string }) {
949
- return subscriptionsImpl.getTrialStatus(this.component, ctx, opts);
950
- }
951
-
952
- // INVOICE METHODS
953
-
954
938
  async listInvoices(
955
939
  ctx: RunCtx,
956
940
  opts?: {
@@ -1786,6 +1770,8 @@ export class BetterStripe extends ReadContractClient {
1786
1770
  splitCreateUpdate(triggers?.payment?.onCreate, undefined),
1787
1771
  record,
1788
1772
  ),
1773
+ payoutReconciled: (ctx, record) =>
1774
+ reconcilePayoutAndTrigger(component, ctx, record, triggers?.payout),
1789
1775
  payoutUpserted: (ctx, record) =>
1790
1776
  runUpsertAndTrigger<StripeComponentPayout>(
1791
1777
  ctx,
@@ -1,3 +1,12 @@
1
+ import { PayoutWorkerClient } from "./connect/payoutWorker.js";
2
+ export type * from "./connect/payoutWorker.js";
3
+ import {
4
+ collectPayoutSnapshot,
5
+ discardPayoutSnapshot,
6
+ type PayoutCollectionOptions,
7
+ type PayoutCollectionScope,
8
+ } from "./connect/payoutCollection.js";
9
+ export type * from "./connect/payoutCollection.js";
1
10
  import { collectAdoptionProof } from "./connect/payoutActivationProof.js";
2
11
  import {
3
12
  adoptRecipientPayouts,
@@ -97,10 +106,26 @@ const toProvider = (
97
106
  });
98
107
  /** Host authenticates owner/policy/consent. Provider I/O stays in this package. */
99
108
  export class PayoutPolicyClient {
109
+ readonly worker: PayoutWorkerClient;
100
110
  constructor(
101
111
  private readonly component: Component,
102
112
  private readonly provider: () => Stripe,
103
- ) {}
113
+ ) {
114
+ this.worker = new PayoutWorkerClient(
115
+ component,
116
+ (ctx, opts) => this.release(ctx, opts),
117
+ (ctx, opts) => this.reconcilePayout(ctx, opts),
118
+ );
119
+ }
120
+ collectSnapshot(ctx: RunCtx, opts: PayoutCollectionOptions) {
121
+ return collectPayoutSnapshot(this.component, ctx, opts);
122
+ }
123
+ discardSnapshot(
124
+ ctx: RunCtx,
125
+ opts: PayoutCollectionScope & { batchSize?: number },
126
+ ) {
127
+ return discardPayoutSnapshot(this.component, ctx, opts);
128
+ }
104
129
  adoptPayouts(ctx: RunCtx, opts: PayoutAdoptionOptions) {
105
130
  return adoptRecipientPayouts(this.provider(), this.component, ctx, opts);
106
131
  }
@@ -1,9 +1,12 @@
1
+ import * as checkoutImpl from "./billing/checkout.js";
2
+ import * as subscriptionsImpl from "./billing/subscriptions.js";
1
3
  import * as payoutsImpl from "./connect/payouts.js";
2
4
  import type { ComponentApi } from "../component/_generated/component.js";
3
5
  import type { PaginationOptions, PaginationResult } from "convex/server";
4
6
  import type { Component, RunCtx } from "./helpers.js";
5
7
  import type { PayoutStatus } from "../component/connect/validators.js";
6
8
  import type {
9
+ StripeComponentCheckoutSession,
7
10
  StripeComponentPayout,
8
11
  StripeComponentProduct,
9
12
  StripeComponentPrice,
@@ -52,6 +55,31 @@ export class ReadContractClient {
52
55
  );
53
56
  }
54
57
 
58
+ async getCheckoutSession(
59
+ ctx: RunCtx,
60
+ opts: { sessionId: string },
61
+ ): Promise<StripeComponentCheckoutSession | null> {
62
+ return checkoutImpl.getCheckoutSession(this.component, ctx, opts);
63
+ }
64
+
65
+ async getCheckoutSessionByStripeId(
66
+ ctx: RunCtx,
67
+ opts: { stripeSessionId: string },
68
+ ): Promise<StripeComponentCheckoutSession | null> {
69
+ return checkoutImpl.getCheckoutSessionByStripeId(this.component, ctx, opts);
70
+ }
71
+
72
+ async listCheckoutSessionsByUser(
73
+ ctx: RunCtx,
74
+ opts: { userId: string; status?: string },
75
+ ): Promise<StripeComponentCheckoutSession[]> {
76
+ return checkoutImpl.listCheckoutSessionsByUser(this.component, ctx, opts);
77
+ }
78
+
79
+ async getTrialStatus(ctx: RunCtx, opts: { subscriptionId: string }) {
80
+ return subscriptionsImpl.getTrialStatus(this.component, ctx, opts);
81
+ }
82
+
55
83
  async getPayout(ctx: RunCtx, opts: { payoutId: string }) {
56
84
  return payoutsImpl.getPayout(this.component, ctx, opts);
57
85
  }
@@ -243,6 +243,7 @@ export type TriggerDispatcherName =
243
243
  | "invoiceUpserted"
244
244
  | "paymentUpserted"
245
245
  | "payoutUpserted"
246
+ | "payoutReconciled"
246
247
  | "refundUpserted"
247
248
  | "disputeUpserted";
248
249
 
@@ -207,6 +207,7 @@ export const DISPATCHER_UPSERT_PATHS: Record<TriggerDispatcherName, string> = {
207
207
  invoiceUpserted: "billing/mutations/upsertInvoice",
208
208
  paymentUpserted: "connect/mutations/upsertPayment",
209
209
  payoutUpserted: "connect/mutations/upsertPayout",
210
+ payoutReconciled: "payoutSync/mutations/finish",
210
211
  refundUpserted: "connect/mutations/upsertRefund",
211
212
  disputeUpserted: "connect/mutations/upsertDispute",
212
213
  };
@@ -85,6 +85,11 @@ function fixture(
85
85
  },
86
86
  },
87
87
  };
88
+ const retrieve = vi
89
+ .spyOn(getStripeClient(key).payouts, "retrieve")
90
+ .mockImplementation(
91
+ async () => ({ ...event.data.object }) as Stripe.Response<Stripe.Payout>,
92
+ );
88
93
  const payout = () => t.query(payoutRef, { stripePayoutId: "po_qa_audit" });
89
94
  const deliver = async (config: RegisterRoutesConfig = {}, tamper = false) => {
90
95
  const body = JSON.stringify(event);
@@ -106,7 +111,7 @@ function fixture(
106
111
  return { status: response.status, body: await response.json() };
107
112
  });
108
113
  };
109
- return { t, event, payout, deliver };
114
+ return { t, event, payout, deliver, retrieve };
110
115
  }
111
116
 
112
117
  it("reconciles signed cancellation before callbacks and deduplicates replay", async () => {
@@ -158,6 +163,7 @@ it("reconciles signed cancellation before callbacks and deduplicates replay", as
158
163
  await f.t.query(ledgerRef, { stripeEventId: f.event.id }),
159
164
  ).toMatchObject({ status: "processed" });
160
165
  expect(transport).not.toHaveBeenCalled();
166
+ expect(f.retrieve).toHaveBeenCalledTimes(2);
161
167
  });
162
168
 
163
169
  it("rejects a tampered cancellation before storage or callbacks", async () => {
@@ -167,6 +173,7 @@ it("rejects a tampered cancellation before storage or callbacks", async () => {
167
173
  expect(await f.payout()).toBeNull();
168
174
  expect(await f.t.query(ledgerRef, { stripeEventId: f.event.id })).toBeNull();
169
175
  expect(onEvent).not.toHaveBeenCalled();
176
+ expect(f.retrieve).not.toHaveBeenCalled();
170
177
  expect(transport).not.toHaveBeenCalled();
171
178
  });
172
179
 
@@ -188,6 +195,12 @@ it.each([
188
195
  });
189
196
  expect(await f.payout()).toEqual(first);
190
197
  expect(transport).not.toHaveBeenCalled();
198
+ expect(f.retrieve).toHaveBeenCalledOnce();
199
+ expect(f.retrieve).toHaveBeenCalledWith(
200
+ "po_qa_audit",
201
+ {},
202
+ { stripeAccount: "acct_qa_recipient" },
203
+ );
191
204
  },
192
205
  );
193
206
 
@@ -0,0 +1,168 @@
1
+ // @vitest-environment edge-runtime
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import {
4
+ payoutFixture,
5
+ observationRef,
6
+ } from "./payoutReconciliationTestHelpers.js";
7
+ afterEach(() => vi.restoreAllMocks());
8
+ it("uses current provider state instead of a signed older pending body and deduplicates replay", async () => {
9
+ const f = payoutFixture();
10
+ const observed: string[] = [];
11
+ const config = {
12
+ onEvent: async () => {
13
+ observed.push((await f.row())!.status);
14
+ },
15
+ };
16
+ expect((await f.deliver(config)).status).toBe(200);
17
+ expect(await f.row()).toMatchObject({
18
+ status: "paid",
19
+ accountId: "acct_recipient",
20
+ });
21
+ expect(observed).toEqual(["paid"]);
22
+ expect(f.retrieve).toHaveBeenCalledWith(
23
+ "po_current",
24
+ {},
25
+ { stripeAccount: "acct_recipient" },
26
+ );
27
+ expect(await f.deliver(config)).toMatchObject({
28
+ status: 200,
29
+ body: { deduplicated: true },
30
+ });
31
+ expect(f.retrieve).toHaveBeenCalledTimes(1);
32
+ expect(observed).toEqual(["paid"]);
33
+ });
34
+ it("holds unavailable provider state without applying the signed body or calling application hooks", async () => {
35
+ const f = payoutFixture();
36
+ const onEvent = vi.fn();
37
+ f.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
38
+ expect((await f.deliver({ onEvent })).status).toBe(500);
39
+ expect(await f.row()).toBeNull();
40
+ expect(onEvent).not.toHaveBeenCalled();
41
+ expect(await f.ledger()).toMatchObject({ status: "failed" });
42
+ expect((await f.deliver({ onEvent })).status).toBe(200);
43
+ expect(await f.row()).toMatchObject({ status: "paid" });
44
+ });
45
+
46
+ it("commits synchronized callbacks with the accepted generation and rolls back callback failures", async () => {
47
+ let canCommit = false;
48
+ const f = payoutFixture({
49
+ payout: {
50
+ onCreate: async (ctx, doc) => {
51
+ await ctx.runMutation(observationRef, { status: doc.status });
52
+ if (!canCommit) throw new Error("Callback failed");
53
+ },
54
+ onUpdate: async (ctx, doc, old) => {
55
+ await ctx.runMutation(observationRef, {
56
+ status: doc.status,
57
+ oldStatus: old.status,
58
+ });
59
+ },
60
+ },
61
+ });
62
+ expect((await f.deliver()).status).toBe(500);
63
+ expect(await f.row()).toBeNull();
64
+ expect(
65
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
66
+ ).toEqual([]);
67
+ canCommit = true;
68
+ expect((await f.deliver()).status).toBe(200);
69
+ expect(await f.row()).toMatchObject({ status: "paid" });
70
+ expect(
71
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
72
+ ).toHaveLength(1);
73
+ f.event.id = "evt_same_current";
74
+ expect((await f.deliver()).status).toBe(200);
75
+ expect(
76
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
77
+ ).toHaveLength(1);
78
+ f.set({
79
+ status: "failed",
80
+ failure_code: "account_closed",
81
+ failure_message: "Closed",
82
+ });
83
+ f.event.id = "evt_later_failure";
84
+ expect((await f.deliver()).status).toBe(200);
85
+ expect(await f.row()).toMatchObject({
86
+ status: "failed",
87
+ failureCode: "account_closed",
88
+ });
89
+ expect(
90
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
91
+ ).toMatchObject([
92
+ { status: "paid" },
93
+ { status: "failed", oldStatus: "paid" },
94
+ ]);
95
+ });
96
+
97
+ it("prevents a delayed old provider response from repeating or regressing a synchronized callback", async () => {
98
+ const f = payoutFixture({
99
+ payout: {
100
+ onCreate: async (ctx, doc) => {
101
+ await ctx.runMutation(observationRef, { status: doc.status });
102
+ },
103
+ onUpdate: async (ctx, doc, old) => {
104
+ await ctx.runMutation(observationRef, {
105
+ status: doc.status,
106
+ oldStatus: old.status,
107
+ });
108
+ },
109
+ },
110
+ });
111
+ let resolve!: (value: ReturnType<typeof f.current>) => void;
112
+ const response = new Promise<ReturnType<typeof f.current>>((done) => {
113
+ resolve = done;
114
+ });
115
+ f.retrieve.mockImplementationOnce(async () => (await response) as never);
116
+ const old = { ...f.current(), status: "pending" as const };
117
+ const first = f.deliver();
118
+ await vi.waitFor(() => expect(f.retrieve).toHaveBeenCalledTimes(1));
119
+ f.event.id = "evt_concurrent_newer";
120
+ expect((await f.deliver()).status).toBe(200);
121
+ resolve(old);
122
+ expect((await first).status).toBe(200);
123
+ expect(await f.row()).toMatchObject({ status: "paid" });
124
+ expect(
125
+ await f.t.run((ctx) => ctx.db.query("observations").collect()),
126
+ ).toMatchObject([{ status: "paid" }]);
127
+ });
128
+ it("preserves an accepted row when a later provider read fails", async () => {
129
+ const f = payoutFixture();
130
+ await f.deliver();
131
+ const original = await f.row();
132
+ f.event.id = "evt_provider_unavailable_later";
133
+ f.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
134
+ expect((await f.deliver()).status).toBe(500);
135
+ expect(await f.row()).toEqual(original);
136
+ expect((await f.deliver()).status).toBe(200);
137
+ expect(await f.row()).toEqual(original);
138
+ });
139
+ it("uses explicit platform scope without adopting metadata or bank account IDs", async () => {
140
+ const f = payoutFixture();
141
+ f.event.account = undefined;
142
+ expect((await f.deliver()).status).toBe(200);
143
+ expect(await f.row()).toMatchObject({
144
+ accountId: "platform",
145
+ status: "paid",
146
+ });
147
+ expect(f.retrieve).toHaveBeenCalledWith("po_current", {}, {});
148
+ });
149
+ it("rejects a foreign signed account for an existing payout before any provider read or callback", async () => {
150
+ const f = payoutFixture();
151
+ await f.deliver();
152
+ const original = await f.row();
153
+ f.event.id = "evt_foreign_account";
154
+ f.event.account = "acct_foreign";
155
+ const onEvent = vi.fn();
156
+ expect((await f.deliver({ onEvent })).status).toBe(500);
157
+ expect(await f.row()).toEqual(original);
158
+ expect(f.retrieve).toHaveBeenCalledTimes(1);
159
+ expect(onEvent).not.toHaveBeenCalled();
160
+ });
161
+ it("rejects a provider response for a different payout without adopting it", async () => {
162
+ const f = payoutFixture();
163
+ f.set({ id: "po_foreign" });
164
+ const onEvent = vi.fn();
165
+ expect((await f.deliver({ onEvent })).status).toBe(500);
166
+ expect(await f.row()).toBeNull();
167
+ expect(onEvent).not.toHaveBeenCalled();
168
+ });
@@ -0,0 +1,72 @@
1
+ import type Stripe from "stripe";
2
+ import type { Component } from "../helpers.js";
3
+ import type {
4
+ SyncTriggerCtx,
5
+ SyncTriggers,
6
+ StripeComponentPayout,
7
+ } from "../types.js";
8
+ import {
9
+ preparePayoutSync,
10
+ type PayoutSyncResult,
11
+ type PreparedPayoutSync,
12
+ } from "../connect/payoutSync.js";
13
+ import {
14
+ componentRef,
15
+ dispatchUpsert,
16
+ type WebhookContext,
17
+ } from "./helpers.js";
18
+
19
+ export async function handlePayoutEvent(
20
+ whCtx: WebhookContext,
21
+ payout: Stripe.Payout,
22
+ accountId?: string,
23
+ ) {
24
+ const prepared = await preparePayoutSync(
25
+ whCtx.stripe,
26
+ whCtx.component,
27
+ whCtx.ctx,
28
+ {
29
+ stripePayoutId: payout.id,
30
+ scope:
31
+ accountId === undefined
32
+ ? { type: "platform" }
33
+ : { type: "connected", accountId },
34
+ },
35
+ );
36
+ try {
37
+ await dispatchUpsert(whCtx, "payoutReconciled", prepared);
38
+ } catch (error) {
39
+ await whCtx.ctx.runMutation(
40
+ componentRef(whCtx.component, "payoutSync/mutations/markUnknown"),
41
+ {
42
+ stripePayoutId: prepared.stripePayoutId,
43
+ accountId: prepared.accountId,
44
+ generation: prepared.generation,
45
+ },
46
+ );
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /** The host mutation commits the generation and application callback together. */
52
+ export async function reconcilePayoutAndTrigger(
53
+ component: Component,
54
+ ctx: SyncTriggerCtx,
55
+ record: Record<string, unknown>,
56
+ triggers: SyncTriggers["payout"],
57
+ ) {
58
+ const prepared = record as PreparedPayoutSync;
59
+ const old = triggers
60
+ ? ((await ctx.runQuery(
61
+ componentRef(component, "connect/queries/getPayoutByStripeId"),
62
+ { stripePayoutId: prepared.stripePayoutId },
63
+ )) as StripeComponentPayout | null)
64
+ : null;
65
+ const result = (await ctx.runMutation(
66
+ componentRef(component, "payoutSync/mutations/finish"),
67
+ prepared,
68
+ )) as PayoutSyncResult;
69
+ if (result.status !== "accepted" || !result.changed) return;
70
+ if (old) await triggers?.onUpdate?.(ctx, result.payout, old);
71
+ else await triggers?.onCreate?.(ctx, result.payout);
72
+ }