@fantastic.dev/better-stripe 0.23.1 → 0.23.2
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 +6 -0
- package/README.md +1 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +42 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutSync.d.ts +32 -0
- package/dist/client/connect/payoutSync.d.ts.map +1 -0
- package/dist/client/connect/payoutSync.js +48 -0
- package/dist/client/connect/payoutSync.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +1263 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
- package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
- package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
- package/dist/client/connect/payoutSyncValidation.js +54 -0
- package/dist/client/connect/payoutSyncValidation.js.map +1 -0
- package/dist/client/index.d.ts +26 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +8 -0
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +7 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/component/_generated/component.d.ts +69 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +4 -27
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/payoutWrites.d.ts +21 -0
- package/dist/component/connect/payoutWrites.d.ts.map +1 -0
- package/dist/component/connect/payoutWrites.js +19 -0
- package/dist/component/connect/payoutWrites.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +44 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutSync/helpers.d.ts +44 -0
- package/dist/component/payoutSync/helpers.d.ts.map +1 -0
- package/dist/component/payoutSync/helpers.js +60 -0
- package/dist/component/payoutSync/helpers.js.map +1 -0
- package/dist/component/payoutSync/mutations.d.ts +51 -0
- package/dist/component/payoutSync/mutations.d.ts.map +1 -0
- package/dist/component/payoutSync/mutations.js +88 -0
- package/dist/component/payoutSync/mutations.js.map +1 -0
- package/dist/component/payoutSync/queries.d.ts +16 -0
- package/dist/component/payoutSync/queries.d.ts.map +1 -0
- package/dist/component/payoutSync/queries.js +11 -0
- package/dist/component/payoutSync/queries.js.map +1 -0
- package/dist/component/payoutSync/schema.d.ts +46 -0
- package/dist/component/payoutSync/schema.d.ts.map +1 -0
- package/dist/component/payoutSync/schema.js +22 -0
- package/dist/component/payoutSync/schema.js.map +1 -0
- package/dist/component/payoutSync/validators.d.ts +137 -0
- package/dist/component/payoutSync/validators.d.ts.map +1 -0
- package/dist/component/payoutSync/validators.js +19 -0
- package/dist/component/payoutSync/validators.js.map +1 -0
- package/dist/component/schema.d.ts +21 -0
- package/dist/component/schema.js +2 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/read-contracts.md +40 -5
- package/package.json +1 -1
- package/src/client/connect/payoutSync.test.ts +313 -0
- package/src/client/connect/payoutSync.ts +93 -0
- package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
- package/src/client/connect/payoutSyncValidation.ts +74 -0
- package/src/client/index.ts +14 -11
- package/src/client/readContracts.ts +12 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +82 -0
- package/src/component/connect/mutations.ts +4 -30
- package/src/component/connect/payoutWrites.ts +25 -0
- package/src/component/payout-sync.test.ts +117 -0
- package/src/component/payoutSync/helpers.ts +86 -0
- package/src/component/payoutSync/mutations.ts +102 -0
- package/src/component/payoutSync/queries.ts +10 -0
- package/src/component/payoutSync/schema.ts +25 -0
- package/src/component/payoutSync/validators.ts +21 -0
- package/src/component/schema.ts +2 -0
package/src/client/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import * as payoutSyncImpl from "./connect/payoutSync.js";
|
|
2
|
+
export type {
|
|
3
|
+
PayoutSyncOptions,
|
|
4
|
+
PayoutSyncResult,
|
|
5
|
+
} from "./connect/payoutSync.js";
|
|
1
6
|
import { PayoutPolicyClient } from "./payoutPolicy.js";
|
|
2
7
|
export * from "./payoutPolicy.js";
|
|
3
8
|
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
@@ -78,10 +83,7 @@ import { v } from "convex/values";
|
|
|
78
83
|
import Stripe from "stripe";
|
|
79
84
|
|
|
80
85
|
import type { ComponentApi } from "../component/_generated/component.js";
|
|
81
|
-
import type {
|
|
82
|
-
DisputeStatus,
|
|
83
|
-
PayoutStatus,
|
|
84
|
-
} from "../component/connect/validators.js";
|
|
86
|
+
import type { DisputeStatus } from "../component/connect/validators.js";
|
|
85
87
|
import type { SplitRecipient } from "../component/lib/fees.js";
|
|
86
88
|
import * as checkoutImpl from "./billing/checkout.js";
|
|
87
89
|
import * as invoicesImpl from "./billing/invoices.js";
|
|
@@ -1300,15 +1302,16 @@ export class BetterStripe extends ReadContractClient {
|
|
|
1300
1302
|
return payoutsImpl.getAccountBalance(this.stripe(), opts);
|
|
1301
1303
|
}
|
|
1302
1304
|
|
|
1303
|
-
async
|
|
1304
|
-
return payoutsImpl.getPayout(this.component, ctx, opts);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
async listPayouts(
|
|
1305
|
+
async syncPayoutFromStripe(
|
|
1308
1306
|
ctx: RunCtx,
|
|
1309
|
-
opts
|
|
1307
|
+
opts: payoutSyncImpl.PayoutSyncOptions,
|
|
1310
1308
|
) {
|
|
1311
|
-
return
|
|
1309
|
+
return payoutSyncImpl.syncPayoutFromStripe(
|
|
1310
|
+
this.stripe(),
|
|
1311
|
+
this.component,
|
|
1312
|
+
ctx,
|
|
1313
|
+
opts,
|
|
1314
|
+
);
|
|
1312
1315
|
}
|
|
1313
1316
|
|
|
1314
1317
|
// REFUND METHODS
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as payoutsImpl from "./connect/payouts.js";
|
|
1
2
|
import type { ComponentApi } from "../component/_generated/component.js";
|
|
2
3
|
import type { PaginationOptions, PaginationResult } from "convex/server";
|
|
3
4
|
import type { Component, RunCtx } from "./helpers.js";
|
|
@@ -51,6 +52,17 @@ export class ReadContractClient {
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
async getPayout(ctx: RunCtx, opts: { payoutId: string }) {
|
|
56
|
+
return payoutsImpl.getPayout(this.component, ctx, opts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async listPayouts(
|
|
60
|
+
ctx: RunCtx,
|
|
61
|
+
opts?: { stripeAccountId?: string; status?: PayoutStatus; limit?: number },
|
|
62
|
+
) {
|
|
63
|
+
return payoutsImpl.listPayouts(this.component, ctx, opts);
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
async getAccountEarningsByCurrency(
|
|
55
67
|
ctx: RunCtx,
|
|
56
68
|
opts: { destinationAccountId: string; previewLimit?: number },
|
|
@@ -12,6 +12,7 @@ import type * as billing_mutations from "../billing/mutations.js";
|
|
|
12
12
|
import type * as billing_queries from "../billing/queries.js";
|
|
13
13
|
import type * as billing_validators from "../billing/validators.js";
|
|
14
14
|
import type * as connect_mutations from "../connect/mutations.js";
|
|
15
|
+
import type * as connect_payoutWrites from "../connect/payoutWrites.js";
|
|
15
16
|
import type * as connect_queries from "../connect/queries.js";
|
|
16
17
|
import type * as connect_validators from "../connect/validators.js";
|
|
17
18
|
import type * as core_mutations from "../core/mutations.js";
|
|
@@ -45,6 +46,10 @@ import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
|
|
|
45
46
|
import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
|
|
46
47
|
import type * as payoutRelease_observation from "../payoutRelease/observation.js";
|
|
47
48
|
import type * as payoutRelease_queries from "../payoutRelease/queries.js";
|
|
49
|
+
import type * as payoutSync_helpers from "../payoutSync/helpers.js";
|
|
50
|
+
import type * as payoutSync_mutations from "../payoutSync/mutations.js";
|
|
51
|
+
import type * as payoutSync_queries from "../payoutSync/queries.js";
|
|
52
|
+
import type * as payoutSync_validators from "../payoutSync/validators.js";
|
|
48
53
|
import type * as products_mutations from "../products/mutations.js";
|
|
49
54
|
import type * as products_queries from "../products/queries.js";
|
|
50
55
|
import type * as products_validators from "../products/validators.js";
|
|
@@ -67,6 +72,7 @@ const fullApi: ApiFromModules<{
|
|
|
67
72
|
"billing/queries": typeof billing_queries;
|
|
68
73
|
"billing/validators": typeof billing_validators;
|
|
69
74
|
"connect/mutations": typeof connect_mutations;
|
|
75
|
+
"connect/payoutWrites": typeof connect_payoutWrites;
|
|
70
76
|
"connect/queries": typeof connect_queries;
|
|
71
77
|
"connect/validators": typeof connect_validators;
|
|
72
78
|
"core/mutations": typeof core_mutations;
|
|
@@ -100,6 +106,10 @@ const fullApi: ApiFromModules<{
|
|
|
100
106
|
"payoutRelease/mutations": typeof payoutRelease_mutations;
|
|
101
107
|
"payoutRelease/observation": typeof payoutRelease_observation;
|
|
102
108
|
"payoutRelease/queries": typeof payoutRelease_queries;
|
|
109
|
+
"payoutSync/helpers": typeof payoutSync_helpers;
|
|
110
|
+
"payoutSync/mutations": typeof payoutSync_mutations;
|
|
111
|
+
"payoutSync/queries": typeof payoutSync_queries;
|
|
112
|
+
"payoutSync/validators": typeof payoutSync_validators;
|
|
103
113
|
"products/mutations": typeof products_mutations;
|
|
104
114
|
"products/queries": typeof products_queries;
|
|
105
115
|
"products/validators": typeof products_validators;
|
|
@@ -3155,6 +3155,88 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
3155
3155
|
>;
|
|
3156
3156
|
};
|
|
3157
3157
|
};
|
|
3158
|
+
payoutSync: {
|
|
3159
|
+
mutations: {
|
|
3160
|
+
begin: FunctionReference<
|
|
3161
|
+
"mutation",
|
|
3162
|
+
"internal",
|
|
3163
|
+
{ accountId: string; stripePayoutId: string },
|
|
3164
|
+
number,
|
|
3165
|
+
Name
|
|
3166
|
+
>;
|
|
3167
|
+
finish: FunctionReference<
|
|
3168
|
+
"mutation",
|
|
3169
|
+
"internal",
|
|
3170
|
+
{
|
|
3171
|
+
accountId: string;
|
|
3172
|
+
generation: number;
|
|
3173
|
+
payout: {
|
|
3174
|
+
accountId: string;
|
|
3175
|
+
amount: number;
|
|
3176
|
+
arrivalDate?: string;
|
|
3177
|
+
currency: string;
|
|
3178
|
+
failureCode?: string;
|
|
3179
|
+
failureMessage?: string;
|
|
3180
|
+
metadata?: Record<string, string>;
|
|
3181
|
+
method?: string;
|
|
3182
|
+
status: "pending" | "paid" | "failed" | "canceled" | "in_transit";
|
|
3183
|
+
stripePayoutId: string;
|
|
3184
|
+
};
|
|
3185
|
+
stripePayoutId: string;
|
|
3186
|
+
},
|
|
3187
|
+
| {
|
|
3188
|
+
changed: boolean;
|
|
3189
|
+
generation: number;
|
|
3190
|
+
payout: {
|
|
3191
|
+
_creationTime: number;
|
|
3192
|
+
_id: string;
|
|
3193
|
+
accountId: string;
|
|
3194
|
+
amount: number;
|
|
3195
|
+
arrivalDate?: string;
|
|
3196
|
+
currency: string;
|
|
3197
|
+
failureCode?: string;
|
|
3198
|
+
failureMessage?: string;
|
|
3199
|
+
metadata?: any;
|
|
3200
|
+
method?: string;
|
|
3201
|
+
status:
|
|
3202
|
+
"pending" | "paid" | "failed" | "canceled" | "in_transit";
|
|
3203
|
+
stripePayoutId: string;
|
|
3204
|
+
totalsRecorded?: true;
|
|
3205
|
+
};
|
|
3206
|
+
status: "accepted";
|
|
3207
|
+
}
|
|
3208
|
+
| { generation: number; status: "superseded" },
|
|
3209
|
+
Name
|
|
3210
|
+
>;
|
|
3211
|
+
markUnknown: FunctionReference<
|
|
3212
|
+
"mutation",
|
|
3213
|
+
"internal",
|
|
3214
|
+
{ accountId: string; generation: number; stripePayoutId: string },
|
|
3215
|
+
null,
|
|
3216
|
+
Name
|
|
3217
|
+
>;
|
|
3218
|
+
};
|
|
3219
|
+
queries: {
|
|
3220
|
+
get: FunctionReference<
|
|
3221
|
+
"query",
|
|
3222
|
+
"internal",
|
|
3223
|
+
{ accountId: string; stripePayoutId: string },
|
|
3224
|
+
{
|
|
3225
|
+
_creationTime: number;
|
|
3226
|
+
_id: string;
|
|
3227
|
+
acceptedAt?: number;
|
|
3228
|
+
acceptedFingerprint?: string;
|
|
3229
|
+
acceptedGeneration?: number;
|
|
3230
|
+
accountId: string;
|
|
3231
|
+
generation: number;
|
|
3232
|
+
startedAt: number;
|
|
3233
|
+
status: "pending" | "accepted" | "unknown";
|
|
3234
|
+
stripePayoutId: string;
|
|
3235
|
+
} | null,
|
|
3236
|
+
Name
|
|
3237
|
+
>;
|
|
3238
|
+
};
|
|
3239
|
+
};
|
|
3158
3240
|
products: {
|
|
3159
3241
|
mutations: {
|
|
3160
3242
|
upsertPrice: FunctionReference<
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writePayout } from "./payoutWrites.js";
|
|
1
2
|
import { v } from "convex/values";
|
|
2
3
|
|
|
3
4
|
import type { Doc, Id } from "../_generated/dataModel";
|
|
@@ -8,7 +9,7 @@ import { paymentSeller } from "../lib/paymentSeller";
|
|
|
8
9
|
import {
|
|
9
10
|
disputeFields,
|
|
10
11
|
paymentStatusValidator,
|
|
11
|
-
|
|
12
|
+
payoutFields,
|
|
12
13
|
refundFields,
|
|
13
14
|
transferFields,
|
|
14
15
|
} from "./validators";
|
|
@@ -113,37 +114,10 @@ export const upsertPayment = mutation({
|
|
|
113
114
|
// Payout mutations
|
|
114
115
|
|
|
115
116
|
export const upsertPayout = mutation({
|
|
116
|
-
args:
|
|
117
|
-
stripePayoutId: v.string(),
|
|
118
|
-
accountId: v.string(),
|
|
119
|
-
amount: v.number(),
|
|
120
|
-
currency: v.string(),
|
|
121
|
-
status: payoutStatusValidator,
|
|
122
|
-
arrivalDate: v.optional(v.string()),
|
|
123
|
-
method: v.optional(v.string()),
|
|
124
|
-
failureCode: v.optional(v.string()),
|
|
125
|
-
failureMessage: v.optional(v.string()),
|
|
126
|
-
metadata: v.optional(v.any()),
|
|
127
|
-
},
|
|
117
|
+
args: payoutFields,
|
|
128
118
|
returns: v.null(),
|
|
129
119
|
handler: async (ctx, args) => {
|
|
130
|
-
|
|
131
|
-
.query("payouts")
|
|
132
|
-
.withIndex("by_stripe_payout_id", (q) =>
|
|
133
|
-
q.eq("stripePayoutId", args.stripePayoutId),
|
|
134
|
-
)
|
|
135
|
-
.first();
|
|
136
|
-
|
|
137
|
-
if (existing) {
|
|
138
|
-
await ctx.db.patch("payouts", existing._id, {
|
|
139
|
-
...args,
|
|
140
|
-
failureCode: args.failureCode,
|
|
141
|
-
failureMessage: args.failureMessage,
|
|
142
|
-
});
|
|
143
|
-
} else {
|
|
144
|
-
await ctx.db.insert("payouts", args);
|
|
145
|
-
}
|
|
146
|
-
|
|
120
|
+
await writePayout(ctx, args);
|
|
147
121
|
return null;
|
|
148
122
|
},
|
|
149
123
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
2
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
3
|
+
type PayoutData = Omit<
|
|
4
|
+
Doc<"payouts">,
|
|
5
|
+
"_id" | "_creationTime" | "totalsRecorded"
|
|
6
|
+
>;
|
|
7
|
+
/** The caller's triggered DB maintains the existing exact currency summaries. */
|
|
8
|
+
export async function writePayout(ctx: MutationCtx, args: PayoutData) {
|
|
9
|
+
const existing = await ctx.db
|
|
10
|
+
.query("payouts")
|
|
11
|
+
.withIndex("by_stripe_payout_id", (q) =>
|
|
12
|
+
q.eq("stripePayoutId", args.stripePayoutId),
|
|
13
|
+
)
|
|
14
|
+
.first();
|
|
15
|
+
const id = existing?._id ?? (await ctx.db.insert("payouts", args));
|
|
16
|
+
if (existing)
|
|
17
|
+
await ctx.db.patch("payouts", id, {
|
|
18
|
+
...args,
|
|
19
|
+
failureCode: args.failureCode,
|
|
20
|
+
failureMessage: args.failureMessage,
|
|
21
|
+
});
|
|
22
|
+
const saved = await ctx.db.get("payouts", id);
|
|
23
|
+
if (!saved) throw new Error("Payout was not found after synchronization.");
|
|
24
|
+
return saved;
|
|
25
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
7
|
+
const scope = { stripePayoutId: "po_one", accountId: "acct_owner" };
|
|
8
|
+
const payout = {
|
|
9
|
+
...scope,
|
|
10
|
+
amount: 500,
|
|
11
|
+
currency: "jpy",
|
|
12
|
+
status: "paid" as const,
|
|
13
|
+
arrivalDate: "1970-01-01T00:03:20.000Z",
|
|
14
|
+
method: "standard",
|
|
15
|
+
metadata: {},
|
|
16
|
+
};
|
|
17
|
+
const mutation = (name: string) =>
|
|
18
|
+
makeFunctionReference<"mutation">(`payoutSync/mutations:${name}`);
|
|
19
|
+
async function setup() {
|
|
20
|
+
const t = convexTest(schema, modules);
|
|
21
|
+
const generation = await t.mutation(mutation("begin"), scope);
|
|
22
|
+
return { t, args: { ...scope, generation, payout } };
|
|
23
|
+
}
|
|
24
|
+
it("replays a committed generation without changing its acceptance time or financial row", async () => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
try {
|
|
27
|
+
vi.setSystemTime(1000);
|
|
28
|
+
const { t, args } = await setup();
|
|
29
|
+
const first = await t.mutation(mutation("finish"), args);
|
|
30
|
+
const oldRow = await t.run((ctx) => ctx.db.query("payouts").unique());
|
|
31
|
+
vi.setSystemTime(2000);
|
|
32
|
+
expect(await t.mutation(mutation("finish"), args)).toMatchObject({
|
|
33
|
+
...first,
|
|
34
|
+
changed: false,
|
|
35
|
+
});
|
|
36
|
+
expect(
|
|
37
|
+
await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
38
|
+
).toMatchObject({ acceptedAt: 1000 });
|
|
39
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").unique())).toEqual(
|
|
40
|
+
oldRow,
|
|
41
|
+
);
|
|
42
|
+
} finally {
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
it("rejects replacing the accepted snapshot under the same generation", async () => {
|
|
47
|
+
const { t, args } = await setup();
|
|
48
|
+
await t.mutation(mutation("finish"), args);
|
|
49
|
+
await expect(
|
|
50
|
+
t.mutation(mutation("finish"), {
|
|
51
|
+
...args,
|
|
52
|
+
payout: { ...payout, status: "pending" },
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow("An accepted payout observation cannot be replaced.");
|
|
55
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").unique())).toMatchObject({
|
|
56
|
+
status: "paid",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
it("ignores stale completion and stale unknown markers before any financial write", async () => {
|
|
60
|
+
const { t, args } = await setup();
|
|
61
|
+
const generation = await t.mutation(mutation("begin"), scope);
|
|
62
|
+
expect(await t.mutation(mutation("finish"), args)).toMatchObject({
|
|
63
|
+
status: "superseded",
|
|
64
|
+
});
|
|
65
|
+
await t.mutation(mutation("markUnknown"), {
|
|
66
|
+
...scope,
|
|
67
|
+
generation: args.generation,
|
|
68
|
+
});
|
|
69
|
+
expect(
|
|
70
|
+
await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
71
|
+
).toMatchObject({ status: "pending", generation });
|
|
72
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").collect())).toEqual([]);
|
|
73
|
+
await t.mutation(mutation("finish"), { ...args, generation });
|
|
74
|
+
await t.mutation(mutation("markUnknown"), { ...scope, generation });
|
|
75
|
+
expect(
|
|
76
|
+
await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
77
|
+
).toMatchObject({ status: "accepted" });
|
|
78
|
+
});
|
|
79
|
+
it.each([{ stripePayoutId: "po_foreign" }, { accountId: "acct_foreign" }])(
|
|
80
|
+
"rejects snapshot identity mismatch %j atomically",
|
|
81
|
+
async (patch) => {
|
|
82
|
+
const { t, args } = await setup();
|
|
83
|
+
await expect(
|
|
84
|
+
t.mutation(mutation("finish"), {
|
|
85
|
+
...args,
|
|
86
|
+
payout: { ...payout, ...patch },
|
|
87
|
+
}),
|
|
88
|
+
).rejects.toThrow(
|
|
89
|
+
"Payout synchronization snapshot identity does not match.",
|
|
90
|
+
);
|
|
91
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").collect())).toEqual([]);
|
|
92
|
+
expect(
|
|
93
|
+
await t.run((ctx) => ctx.db.query("currencyTotals").collect()),
|
|
94
|
+
).toEqual([]);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
it("rejects a foreign claim lookup and generation overflow without changing the claim", async () => {
|
|
98
|
+
const { t } = await setup();
|
|
99
|
+
await expect(
|
|
100
|
+
t.query(makeFunctionReference<"query">("payoutSync/queries:get"), {
|
|
101
|
+
...scope,
|
|
102
|
+
accountId: "acct_foreign",
|
|
103
|
+
}),
|
|
104
|
+
).rejects.toThrow("Payout synchronization account does not match.");
|
|
105
|
+
await t.run(async (ctx) => {
|
|
106
|
+
const row = await ctx.db.query("payoutSyncClaims").unique();
|
|
107
|
+
await ctx.db.patch("payoutSyncClaims", row!._id, {
|
|
108
|
+
generation: Number.MAX_SAFE_INTEGER,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
await expect(t.mutation(mutation("begin"), scope)).rejects.toThrow(
|
|
112
|
+
"Payout synchronization generation exhausted.",
|
|
113
|
+
);
|
|
114
|
+
expect(
|
|
115
|
+
await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
|
|
116
|
+
).toMatchObject({ generation: Number.MAX_SAFE_INTEGER });
|
|
117
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Infer } from "convex/values";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
3
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
4
|
+
import { generateIdempotencyKey } from "../lib/idempotency.js";
|
|
5
|
+
import type { snapshot } from "./validators.js";
|
|
6
|
+
export function validateIdentity(args: {
|
|
7
|
+
stripePayoutId: string;
|
|
8
|
+
accountId: string;
|
|
9
|
+
}) {
|
|
10
|
+
if (
|
|
11
|
+
!/^po_[A-Za-z0-9_]+$/.test(args.stripePayoutId) ||
|
|
12
|
+
args.stripePayoutId.length > 255 ||
|
|
13
|
+
(args.accountId !== "platform" &&
|
|
14
|
+
!/^acct_[A-Za-z0-9_]+$/.test(args.accountId)) ||
|
|
15
|
+
args.accountId.length > 255
|
|
16
|
+
)
|
|
17
|
+
throw new Error("Invalid payout synchronization identity.");
|
|
18
|
+
}
|
|
19
|
+
export async function claimByPayout(
|
|
20
|
+
ctx: QueryCtx | MutationCtx,
|
|
21
|
+
args: { stripePayoutId: string; accountId: string },
|
|
22
|
+
) {
|
|
23
|
+
validateIdentity(args);
|
|
24
|
+
const claim = await ctx.db
|
|
25
|
+
.query("payoutSyncClaims")
|
|
26
|
+
.withIndex("by_stripe_payout_id", (q) =>
|
|
27
|
+
q.eq("stripePayoutId", args.stripePayoutId),
|
|
28
|
+
)
|
|
29
|
+
.unique();
|
|
30
|
+
if (claim && claim.accountId !== args.accountId)
|
|
31
|
+
throw new Error("Payout synchronization account does not match.");
|
|
32
|
+
return claim;
|
|
33
|
+
}
|
|
34
|
+
export async function payoutById(
|
|
35
|
+
ctx: QueryCtx | MutationCtx,
|
|
36
|
+
args: { stripePayoutId: string; accountId: string },
|
|
37
|
+
) {
|
|
38
|
+
const payout = await ctx.db
|
|
39
|
+
.query("payouts")
|
|
40
|
+
.withIndex("by_stripe_payout_id", (q) =>
|
|
41
|
+
q.eq("stripePayoutId", args.stripePayoutId),
|
|
42
|
+
)
|
|
43
|
+
.unique();
|
|
44
|
+
if (payout && payout.accountId !== args.accountId)
|
|
45
|
+
throw new Error("Existing payout account does not match.");
|
|
46
|
+
return payout;
|
|
47
|
+
}
|
|
48
|
+
export function validateSnapshot(p: Infer<typeof snapshot>) {
|
|
49
|
+
validateIdentity(p);
|
|
50
|
+
if (
|
|
51
|
+
!Number.isSafeInteger(p.amount) ||
|
|
52
|
+
p.amount <= 0 ||
|
|
53
|
+
!/^[a-z]{3}$/.test(p.currency) ||
|
|
54
|
+
(p.method !== undefined && !["standard", "instant"].includes(p.method))
|
|
55
|
+
)
|
|
56
|
+
throw new Error("Invalid payout synchronization money or method.");
|
|
57
|
+
if (
|
|
58
|
+
p.arrivalDate !== undefined &&
|
|
59
|
+
(!Number.isFinite(Date.parse(p.arrivalDate)) ||
|
|
60
|
+
new Date(p.arrivalDate).toISOString() !== p.arrivalDate)
|
|
61
|
+
)
|
|
62
|
+
throw new Error("Invalid payout arrival date.");
|
|
63
|
+
if (
|
|
64
|
+
(p.failureCode !== undefined && p.failureCode.length > 255) ||
|
|
65
|
+
(p.failureMessage !== undefined && p.failureMessage.length > 4096)
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Invalid payout failure detail.");
|
|
68
|
+
}
|
|
69
|
+
export async function fingerprint(p: Infer<typeof snapshot> | Doc<"payouts">) {
|
|
70
|
+
return generateIdempotencyKey("payout-sync", {
|
|
71
|
+
snapshot: [
|
|
72
|
+
p.stripePayoutId,
|
|
73
|
+
p.accountId,
|
|
74
|
+
p.amount,
|
|
75
|
+
p.currency,
|
|
76
|
+
p.status,
|
|
77
|
+
p.arrivalDate,
|
|
78
|
+
p.method,
|
|
79
|
+
p.failureCode,
|
|
80
|
+
p.failureMessage,
|
|
81
|
+
Object.entries(p.metadata ?? {}).sort(([a], [b]) =>
|
|
82
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../earnings/triggers.js";
|
|
3
|
+
import { writePayout } from "../connect/payoutWrites.js";
|
|
4
|
+
import {
|
|
5
|
+
claimByPayout,
|
|
6
|
+
payoutById,
|
|
7
|
+
validateSnapshot,
|
|
8
|
+
fingerprint,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
import { identity, prepared, result } from "./validators.js";
|
|
11
|
+
export const begin = mutation({
|
|
12
|
+
args: identity,
|
|
13
|
+
returns: v.number(),
|
|
14
|
+
handler: async (ctx, args) => {
|
|
15
|
+
const claim = await claimByPayout(ctx, args);
|
|
16
|
+
await payoutById(ctx, args);
|
|
17
|
+
const generation = (claim?.generation ?? 0) + 1;
|
|
18
|
+
if (!Number.isSafeInteger(generation))
|
|
19
|
+
throw new Error("Payout synchronization generation exhausted.");
|
|
20
|
+
if (claim)
|
|
21
|
+
await ctx.db.patch("payoutSyncClaims", claim._id, {
|
|
22
|
+
generation,
|
|
23
|
+
status: "pending",
|
|
24
|
+
startedAt: Date.now(),
|
|
25
|
+
});
|
|
26
|
+
else
|
|
27
|
+
await ctx.db.insert("payoutSyncClaims", {
|
|
28
|
+
...args,
|
|
29
|
+
generation,
|
|
30
|
+
status: "pending",
|
|
31
|
+
startedAt: Date.now(),
|
|
32
|
+
});
|
|
33
|
+
return generation;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
export const finish = mutation({
|
|
37
|
+
args: prepared,
|
|
38
|
+
returns: result,
|
|
39
|
+
handler: async (ctx, args) => {
|
|
40
|
+
const claim = await claimByPayout(ctx, args);
|
|
41
|
+
if (!claim) throw new Error("Payout synchronization claim is missing.");
|
|
42
|
+
if (claim.generation !== args.generation)
|
|
43
|
+
return { status: "superseded" as const, generation: args.generation };
|
|
44
|
+
validateSnapshot(args.payout);
|
|
45
|
+
if (
|
|
46
|
+
args.payout.accountId !== args.accountId ||
|
|
47
|
+
args.payout.stripePayoutId !== args.stripePayoutId
|
|
48
|
+
)
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Payout synchronization snapshot identity does not match.",
|
|
51
|
+
);
|
|
52
|
+
const existing = await payoutById(ctx, args);
|
|
53
|
+
if (
|
|
54
|
+
existing &&
|
|
55
|
+
(existing.amount !== args.payout.amount ||
|
|
56
|
+
existing.currency !== args.payout.currency)
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Existing payout original money does not match.");
|
|
59
|
+
const nextFingerprint = await fingerprint(args.payout);
|
|
60
|
+
if (
|
|
61
|
+
claim.status === "accepted" &&
|
|
62
|
+
claim.acceptedFingerprint !== nextFingerprint
|
|
63
|
+
)
|
|
64
|
+
throw new Error("An accepted payout observation cannot be replaced.");
|
|
65
|
+
const changed =
|
|
66
|
+
!existing || (await fingerprint(existing)) !== nextFingerprint;
|
|
67
|
+
if (claim.status === "accepted" && changed)
|
|
68
|
+
throw new Error(
|
|
69
|
+
"Accepted payout state changed outside its synchronization.",
|
|
70
|
+
);
|
|
71
|
+
if (claim.status === "accepted")
|
|
72
|
+
return {
|
|
73
|
+
status: "accepted" as const,
|
|
74
|
+
generation: args.generation,
|
|
75
|
+
changed: false,
|
|
76
|
+
payout: existing!,
|
|
77
|
+
};
|
|
78
|
+
const payout = changed ? await writePayout(ctx, args.payout) : existing!;
|
|
79
|
+
await ctx.db.patch("payoutSyncClaims", claim._id, {
|
|
80
|
+
status: "accepted",
|
|
81
|
+
acceptedGeneration: args.generation,
|
|
82
|
+
acceptedAt: Date.now(),
|
|
83
|
+
acceptedFingerprint: nextFingerprint,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
status: "accepted" as const,
|
|
87
|
+
generation: args.generation,
|
|
88
|
+
changed,
|
|
89
|
+
payout,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
export const markUnknown = mutation({
|
|
94
|
+
args: { ...identity, generation: v.number() },
|
|
95
|
+
returns: v.null(),
|
|
96
|
+
handler: async (ctx, args) => {
|
|
97
|
+
const claim = await claimByPayout(ctx, args);
|
|
98
|
+
if (claim?.generation === args.generation && claim.status === "pending")
|
|
99
|
+
await ctx.db.patch("payoutSyncClaims", claim._id, { status: "unknown" });
|
|
100
|
+
return null;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query } from "../_generated/server.js";
|
|
3
|
+
import { claimByPayout } from "./helpers.js";
|
|
4
|
+
import { claimDoc } from "./schema.js";
|
|
5
|
+
import { identity } from "./validators.js";
|
|
6
|
+
export const get = query({
|
|
7
|
+
args: identity,
|
|
8
|
+
returns: v.union(claimDoc, v.null()),
|
|
9
|
+
handler: claimByPayout,
|
|
10
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { identity } from "./validators.js";
|
|
4
|
+
const fields = {
|
|
5
|
+
...identity,
|
|
6
|
+
generation: v.number(),
|
|
7
|
+
status: v.union(
|
|
8
|
+
v.literal("pending"),
|
|
9
|
+
v.literal("accepted"),
|
|
10
|
+
v.literal("unknown"),
|
|
11
|
+
),
|
|
12
|
+
startedAt: v.number(),
|
|
13
|
+
acceptedGeneration: v.optional(v.number()),
|
|
14
|
+
acceptedAt: v.optional(v.number()),
|
|
15
|
+
acceptedFingerprint: v.optional(v.string()),
|
|
16
|
+
};
|
|
17
|
+
export const claimDoc = v.object({
|
|
18
|
+
...fields,
|
|
19
|
+
_id: v.id("payoutSyncClaims"),
|
|
20
|
+
_creationTime: v.number(),
|
|
21
|
+
});
|
|
22
|
+
/** Operational generations only. Financial facts remain in the existing payouts table. */
|
|
23
|
+
export const claimsTable = defineTable(fields).index("by_stripe_payout_id", [
|
|
24
|
+
"stripePayoutId",
|
|
25
|
+
]);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { payoutFields, payoutDocValidator } from "../connect/validators.js";
|
|
3
|
+
export const identity = { stripePayoutId: v.string(), accountId: v.string() };
|
|
4
|
+
export const snapshot = v.object({
|
|
5
|
+
...payoutFields,
|
|
6
|
+
metadata: v.optional(v.record(v.string(), v.string())),
|
|
7
|
+
});
|
|
8
|
+
export const prepared = {
|
|
9
|
+
...identity,
|
|
10
|
+
generation: v.number(),
|
|
11
|
+
payout: snapshot,
|
|
12
|
+
};
|
|
13
|
+
export const result = v.union(
|
|
14
|
+
v.object({
|
|
15
|
+
status: v.literal("accepted"),
|
|
16
|
+
generation: v.number(),
|
|
17
|
+
changed: v.boolean(),
|
|
18
|
+
payout: payoutDocValidator,
|
|
19
|
+
}),
|
|
20
|
+
v.object({ status: v.literal("superseded"), generation: v.number() }),
|
|
21
|
+
);
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claimsTable as payoutSyncClaimsTable } from "./payoutSync/schema.js";
|
|
1
2
|
import {
|
|
2
3
|
feeBalancesTable as payoutFeeBalancesTable,
|
|
3
4
|
claimsTable as payoutReleaseClaimsTable,
|
|
@@ -37,6 +38,7 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
37
38
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
38
39
|
|
|
39
40
|
export default defineSchema({
|
|
41
|
+
payoutSyncClaims: payoutSyncClaimsTable,
|
|
40
42
|
payoutFeeBalances: payoutFeeBalancesTable,
|
|
41
43
|
payoutReleaseClaims: payoutReleaseClaimsTable,
|
|
42
44
|
payoutSourceAllocations: payoutSourceAllocationsTable,
|