@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.
- package/CHANGELOG.md +13 -0
- package/README.md +7 -1
- package/dist/client/billing/checkout.d.ts +3 -43
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -260
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/checkoutRequest.d.ts +129 -0
- package/dist/client/billing/checkoutRequest.d.ts.map +1 -0
- package/dist/client/billing/checkoutRequest.js +263 -0
- package/dist/client/billing/checkoutRequest.js.map +1 -0
- package/dist/client/billing/preparedCheckout.d.ts +22 -0
- package/dist/client/billing/preparedCheckout.d.ts.map +1 -0
- package/dist/client/billing/preparedCheckout.js +86 -0
- package/dist/client/billing/preparedCheckout.js.map +1 -0
- package/dist/client/checkoutRequests.d.ts +80 -0
- package/dist/client/checkoutRequests.d.ts.map +1 -0
- package/dist/client/checkoutRequests.js +28 -0
- package/dist/client/checkoutRequests.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +464 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutCollection.d.ts +21 -0
- package/dist/client/connect/payoutCollection.d.ts.map +1 -0
- package/dist/client/connect/payoutCollection.js +47 -0
- package/dist/client/connect/payoutCollection.js.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +1805 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js +194 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +232 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts +48 -0
- package/dist/client/connect/payoutWorker.d.ts.map +1 -0
- package/dist/client/connect/payoutWorker.js +78 -0
- package/dist/client/connect/payoutWorker.js.map +1 -0
- package/dist/client/index.d.ts +4 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -13
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +67 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +10 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/client/readContracts.d.ts +14 -1
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +14 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/client/setup.test.d.ts +18 -0
- package/dist/client/setup.test.d.ts.map +1 -0
- package/dist/client/setup.test.js +33 -0
- package/dist/client/setup.test.js.map +1 -0
- package/dist/client/types/triggers.d.ts +1 -1
- package/dist/client/types/triggers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.js +1 -0
- package/dist/client/webhooks/helpers.js.map +1 -1
- package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
- package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliation.js +36 -0
- package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -20
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +293 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/core/mutations.d.ts.map +1 -1
- package/dist/component/core/mutations.js +2 -0
- package/dist/component/core/mutations.js.map +1 -1
- package/dist/component/core/resetSafety.d.ts +4 -0
- package/dist/component/core/resetSafety.d.ts.map +1 -0
- package/dist/component/core/resetSafety.js +54 -0
- package/dist/component/core/resetSafety.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +418 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/earnings/triggers.js +2 -0
- package/dist/component/earnings/triggers.js.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +2 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +3 -1
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/mutations.js +1 -1
- package/dist/component/payoutRelease/schema.d.ts +1 -0
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +1 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/payoutSnapshots/localState.d.ts +89 -0
- package/dist/component/payoutSnapshots/localState.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/localState.js +186 -0
- package/dist/component/payoutSnapshots/localState.js.map +1 -0
- package/dist/component/payoutSnapshots/mutations.d.ts +108 -0
- package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/mutations.js +263 -0
- package/dist/component/payoutSnapshots/mutations.js.map +1 -0
- package/dist/component/payoutSnapshots/revisions.d.ts +12 -0
- package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/revisions.js +173 -0
- package/dist/component/payoutSnapshots/revisions.js.map +1 -0
- package/dist/component/payoutSnapshots/schema.d.ts +285 -0
- package/dist/component/payoutSnapshots/schema.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/schema.js +108 -0
- package/dist/component/payoutSnapshots/schema.js.map +1 -0
- package/dist/component/payoutSnapshots/scope.d.ts +7 -0
- package/dist/component/payoutSnapshots/scope.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/scope.js +8 -0
- package/dist/component/payoutSnapshots/scope.js.map +1 -0
- package/dist/component/payoutWorker/helpers.d.ts +30 -0
- package/dist/component/payoutWorker/helpers.d.ts.map +1 -0
- package/dist/component/payoutWorker/helpers.js +21 -0
- package/dist/component/payoutWorker/helpers.js.map +1 -0
- package/dist/component/payoutWorker/mutations.d.ts +68 -0
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -0
- package/dist/component/payoutWorker/mutations.js +161 -0
- package/dist/component/payoutWorker/mutations.js.map +1 -0
- package/dist/component/payoutWorker/queries.d.ts +123 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -0
- package/dist/component/payoutWorker/queries.js +61 -0
- package/dist/component/payoutWorker/queries.js.map +1 -0
- package/dist/component/payoutWorker/schema.d.ts +91 -0
- package/dist/component/payoutWorker/schema.d.ts.map +1 -0
- package/dist/component/payoutWorker/schema.js +27 -0
- package/dist/component/payoutWorker/schema.js.map +1 -0
- package/dist/component/schema.d.ts +232 -0
- package/dist/component/schema.js +10 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/read-contracts.md +69 -5
- package/docs/settlement.md +128 -1
- package/package.json +1 -1
- package/src/client/billing/checkout.ts +11 -359
- package/src/client/billing/checkoutRequest.ts +362 -0
- package/src/client/billing/prepared-checkout.test.ts +430 -0
- package/src/client/billing/preparedCheckout.ts +143 -0
- package/src/client/checkoutRequests.ts +40 -0
- package/src/client/connect/payoutCollection.test.ts +74 -0
- package/src/client/connect/payoutCollection.ts +82 -0
- package/src/client/connect/payoutExecution.test.ts +1 -211
- package/src/client/connect/payoutExecutionTestHelpers.ts +213 -0
- package/src/client/connect/payoutWorker.test.ts +145 -0
- package/src/client/connect/payoutWorker.ts +144 -0
- package/src/client/index.ts +13 -27
- package/src/client/payoutPolicy.ts +26 -1
- package/src/client/readContracts.ts +28 -0
- package/src/client/types/triggers.ts +1 -0
- package/src/client/webhooks/helpers.ts +1 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
- package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
- package/src/client/webhooks/payoutReconciliation.ts +72 -0
- package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
- package/src/client/webhooks/processors.test.ts +44 -29
- package/src/client/webhooks/processors.ts +1 -27
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +361 -0
- package/src/component/core/mutations.ts +2 -0
- package/src/component/core/resetSafety.ts +67 -0
- package/src/component/earnings/triggers.ts +2 -0
- package/src/component/payout-reset-safety.test.ts +148 -0
- package/src/component/payout-revisions.test.ts +125 -0
- package/src/component/payout-snapshots.test.ts +395 -0
- package/src/component/payout-worker.test.ts +165 -0
- package/src/component/payoutPolicy/schema.ts +3 -1
- package/src/component/payoutRelease/mutations.ts +1 -1
- package/src/component/payoutRelease/schema.ts +1 -0
- package/src/component/payoutSnapshots/localState.ts +238 -0
- package/src/component/payoutSnapshots/mutations.ts +275 -0
- package/src/component/payoutSnapshots/revisions.ts +247 -0
- package/src/component/payoutSnapshots/schema.ts +125 -0
- package/src/component/payoutSnapshots/scope.ts +7 -0
- package/src/component/payoutWorker/helpers.ts +30 -0
- package/src/component/payoutWorker/mutations.ts +181 -0
- package/src/component/payoutWorker/queries.ts +64 -0
- package/src/component/payoutWorker/schema.ts +26 -0
- package/src/component/public.test.ts +2 -2
- 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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|