@fantastic.dev/better-stripe 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +9 -1
- package/dist/client/connect/refunds.d.ts +12 -0
- package/dist/client/connect/refunds.d.ts.map +1 -1
- package/dist/client/connect/refunds.js +8 -0
- package/dist/client/connect/refunds.js.map +1 -1
- package/dist/client/index.d.ts +5 -7
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +36 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/schema.d.ts +2 -0
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +9 -0
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +4 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/refundHistory/queries.d.ts +28 -0
- package/dist/component/refundHistory/queries.d.ts.map +1 -0
- package/dist/component/refundHistory/queries.js +59 -0
- package/dist/component/refundHistory/queries.js.map +1 -0
- package/dist/component/schema.d.ts +2 -0
- package/dist/react/workflows/Costs.d.ts +1 -0
- package/dist/react/workflows/Costs.d.ts.map +1 -1
- package/dist/react/workflows/Costs.js +7 -3
- package/dist/react/workflows/Costs.js.map +1 -1
- package/docs/react-workflows.md +34 -0
- package/package.json +1 -1
- package/src/client/connect/refunds.ts +28 -0
- package/src/client/index.ts +6 -7
- package/src/client/refund-history.test.ts +67 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +56 -0
- package/src/component/connect/schema.ts +9 -0
- package/src/component/refund-history.test.ts +198 -0
- package/src/component/refundHistory/queries.ts +99 -0
- package/src/react/workflows/Costs.test.tsx +21 -0
- package/src/react/workflows/Costs.tsx +9 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type Stripe from "stripe";
|
|
2
|
+
import type { PaginationOptions, PaginationResult } from "convex/server";
|
|
2
3
|
|
|
3
4
|
import type { RefundStatus } from "../../component/connect/validators.js";
|
|
4
5
|
import { throwStripeError } from "../errors.js";
|
|
@@ -12,6 +13,33 @@ import { type RefundActor, isRefundAuthorized } from "./refundActor.js";
|
|
|
12
13
|
|
|
13
14
|
export type { RefundActor } from "./refundActor.js";
|
|
14
15
|
|
|
16
|
+
export type RefundListOptions = {
|
|
17
|
+
stripeAccountId?: string;
|
|
18
|
+
stripePaymentIntentId?: string;
|
|
19
|
+
status?: RefundStatus;
|
|
20
|
+
limit?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RefundPageOptions = Omit<RefundListOptions, "limit"> & {
|
|
24
|
+
paginationOpts: PaginationOptions;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The host must authenticate/authorize scopes before exposing this reactive reader. */
|
|
28
|
+
export async function paginateRefunds(
|
|
29
|
+
component: Component,
|
|
30
|
+
ctx: RunCtx,
|
|
31
|
+
opts: RefundPageOptions,
|
|
32
|
+
): Promise<PaginationResult<StripeComponentRefund>> {
|
|
33
|
+
const { stripeAccountId, ...rest } = opts;
|
|
34
|
+
return await ctx.runQuery(
|
|
35
|
+
componentRef(component, "refundHistory/queries/paginateRefunds"),
|
|
36
|
+
{
|
|
37
|
+
...rest,
|
|
38
|
+
...(stripeAccountId !== undefined ? { accountId: stripeAccountId } : {}),
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
// =============================================================================
|
|
16
44
|
// Refund methods
|
|
17
45
|
// =============================================================================
|
package/src/client/index.ts
CHANGED
|
@@ -79,7 +79,6 @@ import type { ComponentApi } from "../component/_generated/component.js";
|
|
|
79
79
|
import type {
|
|
80
80
|
DisputeStatus,
|
|
81
81
|
PayoutStatus,
|
|
82
|
-
RefundStatus,
|
|
83
82
|
} from "../component/connect/validators.js";
|
|
84
83
|
import type { SplitRecipient } from "../component/lib/fees.js";
|
|
85
84
|
import * as checkoutImpl from "./billing/checkout.js";
|
|
@@ -89,6 +88,7 @@ import * as disputesImpl from "./connect/disputes.js";
|
|
|
89
88
|
import * as paymentMethodsImpl from "./connect/paymentMethods.js";
|
|
90
89
|
import * as payoutsImpl from "./connect/payouts.js";
|
|
91
90
|
import * as refundsImpl from "./connect/refunds.js";
|
|
91
|
+
export type { RefundPageOptions } from "./connect/refunds.js";
|
|
92
92
|
import * as transfersImpl from "./connect/transfers.js";
|
|
93
93
|
import * as accountLinksImpl from "./core/accountLinks.js";
|
|
94
94
|
import * as accountsImpl from "./core/accounts.js";
|
|
@@ -1360,16 +1360,15 @@ export class BetterStripe extends ReadContractClient {
|
|
|
1360
1360
|
|
|
1361
1361
|
async listRefunds(
|
|
1362
1362
|
ctx: RunCtx,
|
|
1363
|
-
opts?:
|
|
1364
|
-
stripeAccountId?: string;
|
|
1365
|
-
stripePaymentIntentId?: string;
|
|
1366
|
-
status?: RefundStatus;
|
|
1367
|
-
limit?: number;
|
|
1368
|
-
},
|
|
1363
|
+
opts?: refundsImpl.RefundListOptions,
|
|
1369
1364
|
): Promise<StripeComponentRefund[]> {
|
|
1370
1365
|
return refundsImpl.listRefunds(this.component, ctx, opts);
|
|
1371
1366
|
}
|
|
1372
1367
|
|
|
1368
|
+
paginateRefunds(ctx: RunCtx, opts: refundsImpl.RefundPageOptions) {
|
|
1369
|
+
return refundsImpl.paginateRefunds(this.component, ctx, opts);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1373
1372
|
// DISPUTE METHODS
|
|
1374
1373
|
|
|
1375
1374
|
async getDisputeByStripeId(
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { componentsGeneric, type FunctionReference } from "convex/server";
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import schema from "../component/schema.js";
|
|
5
|
+
import type { Component, RunCtx } from "./helpers.js";
|
|
6
|
+
import { BetterStripe, type RefundPageOptions } from "./index.js";
|
|
7
|
+
import { initConvexTest } from "./setup.test.js";
|
|
8
|
+
|
|
9
|
+
const modules = import.meta.glob("../component/**/*.{ts,js,tsx,jsx}");
|
|
10
|
+
|
|
11
|
+
it("reads multiple conjunctively scoped pages through the public client without provider credentials", async () => {
|
|
12
|
+
const t = initConvexTest();
|
|
13
|
+
t.registerComponent("betterStripe", schema, modules);
|
|
14
|
+
const component = componentsGeneric().betterStripe as unknown as Component;
|
|
15
|
+
const stripe = new BetterStripe(component, {});
|
|
16
|
+
const ctx = {
|
|
17
|
+
runQuery: (
|
|
18
|
+
ref: FunctionReference<"query">,
|
|
19
|
+
args: Record<string, unknown>,
|
|
20
|
+
) => t.query(ref, args),
|
|
21
|
+
} as unknown as RunCtx;
|
|
22
|
+
for (let index = 0; index < 5; index++) {
|
|
23
|
+
await t.mutation(component.connect.mutations.upsertRefund, {
|
|
24
|
+
stripeRefundId: `re_${index}`,
|
|
25
|
+
accountId: index === 4 ? "acct_foreign" : "acct_owner",
|
|
26
|
+
stripePaymentIntentId: index === 3 ? "pi_foreign" : "pi_original",
|
|
27
|
+
stripeChargeId: "ch_original",
|
|
28
|
+
amount: 500,
|
|
29
|
+
currency: "jpy",
|
|
30
|
+
status: "succeeded",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const opts: RefundPageOptions = {
|
|
34
|
+
stripeAccountId: "acct_owner",
|
|
35
|
+
stripePaymentIntentId: "pi_original",
|
|
36
|
+
status: "succeeded",
|
|
37
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
38
|
+
};
|
|
39
|
+
const first = await stripe.paginateRefunds(ctx, opts);
|
|
40
|
+
expect(first.page).toHaveLength(2);
|
|
41
|
+
expect(first.isDone).toBe(false);
|
|
42
|
+
const last = await stripe.paginateRefunds(ctx, {
|
|
43
|
+
...opts,
|
|
44
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
45
|
+
});
|
|
46
|
+
expect(last.isDone).toBe(true);
|
|
47
|
+
expect([...first.page, ...last.page]).toHaveLength(3);
|
|
48
|
+
for (const refund of [...first.page, ...last.page]) {
|
|
49
|
+
expect(refund).toMatchObject({
|
|
50
|
+
accountId: "acct_owner",
|
|
51
|
+
stripePaymentIntentId: "pi_original",
|
|
52
|
+
stripeChargeId: "ch_original",
|
|
53
|
+
amount: 500,
|
|
54
|
+
currency: "jpy",
|
|
55
|
+
status: "succeeded",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
expect(
|
|
59
|
+
await stripe.paginateRefunds(ctx, {
|
|
60
|
+
...opts,
|
|
61
|
+
stripeAccountId: "acct_absent",
|
|
62
|
+
}),
|
|
63
|
+
).toMatchObject({ page: [], isDone: true });
|
|
64
|
+
expect(
|
|
65
|
+
await stripe.listRefunds(ctx, { stripeAccountId: "acct_owner", limit: 1 }),
|
|
66
|
+
).toHaveLength(1);
|
|
67
|
+
});
|
|
@@ -36,6 +36,7 @@ import type * as lib_stripeFactory from "../lib/stripeFactory.js";
|
|
|
36
36
|
import type * as products_mutations from "../products/mutations.js";
|
|
37
37
|
import type * as products_queries from "../products/queries.js";
|
|
38
38
|
import type * as products_validators from "../products/validators.js";
|
|
39
|
+
import type * as refundHistory_queries from "../refundHistory/queries.js";
|
|
39
40
|
import type * as settlement_mutations from "../settlement/mutations.js";
|
|
40
41
|
import type * as settlement_queries from "../settlement/queries.js";
|
|
41
42
|
import type * as webhooks_mutations from "../webhooks/mutations.js";
|
|
@@ -78,6 +79,7 @@ const fullApi: ApiFromModules<{
|
|
|
78
79
|
"products/mutations": typeof products_mutations;
|
|
79
80
|
"products/queries": typeof products_queries;
|
|
80
81
|
"products/validators": typeof products_validators;
|
|
82
|
+
"refundHistory/queries": typeof refundHistory_queries;
|
|
81
83
|
"settlement/mutations": typeof settlement_mutations;
|
|
82
84
|
"settlement/queries": typeof settlement_queries;
|
|
83
85
|
"webhooks/mutations": typeof webhooks_mutations;
|
|
@@ -2436,6 +2436,62 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2436
2436
|
>;
|
|
2437
2437
|
};
|
|
2438
2438
|
};
|
|
2439
|
+
refundHistory: {
|
|
2440
|
+
queries: {
|
|
2441
|
+
paginateRefunds: FunctionReference<
|
|
2442
|
+
"query",
|
|
2443
|
+
"internal",
|
|
2444
|
+
{
|
|
2445
|
+
accountId?: string;
|
|
2446
|
+
paginationOpts: {
|
|
2447
|
+
cursor: string | null;
|
|
2448
|
+
endCursor?: string | null;
|
|
2449
|
+
id?: number;
|
|
2450
|
+
maximumBytesRead?: number;
|
|
2451
|
+
maximumRowsRead?: number;
|
|
2452
|
+
numItems: number;
|
|
2453
|
+
};
|
|
2454
|
+
status?:
|
|
2455
|
+
| "pending"
|
|
2456
|
+
| "requires_action"
|
|
2457
|
+
| "succeeded"
|
|
2458
|
+
| "failed"
|
|
2459
|
+
| "canceled";
|
|
2460
|
+
stripePaymentIntentId?: string;
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
continueCursor: string;
|
|
2464
|
+
isDone: boolean;
|
|
2465
|
+
page: Array<{
|
|
2466
|
+
_creationTime: number;
|
|
2467
|
+
_id: string;
|
|
2468
|
+
accountId?: string;
|
|
2469
|
+
amount: number;
|
|
2470
|
+
currency: string;
|
|
2471
|
+
failureReason?: string;
|
|
2472
|
+
metadata?: any;
|
|
2473
|
+
reason?:
|
|
2474
|
+
| "duplicate"
|
|
2475
|
+
| "fraudulent"
|
|
2476
|
+
| "requested_by_customer"
|
|
2477
|
+
| "expired_uncaptured_charge";
|
|
2478
|
+
status:
|
|
2479
|
+
| "pending"
|
|
2480
|
+
| "requires_action"
|
|
2481
|
+
| "succeeded"
|
|
2482
|
+
| "failed"
|
|
2483
|
+
| "canceled";
|
|
2484
|
+
stripeChargeId?: string;
|
|
2485
|
+
stripePaymentIntentId?: string;
|
|
2486
|
+
stripeRefundId: string;
|
|
2487
|
+
}>;
|
|
2488
|
+
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
|
|
2489
|
+
splitCursor?: string | null;
|
|
2490
|
+
},
|
|
2491
|
+
Name
|
|
2492
|
+
>;
|
|
2493
|
+
};
|
|
2494
|
+
};
|
|
2439
2495
|
settlement: {
|
|
2440
2496
|
mutations: {
|
|
2441
2497
|
claimCommissionInvoice: FunctionReference<
|
|
@@ -54,6 +54,15 @@ export const refundsTable = defineTable(refundFields)
|
|
|
54
54
|
.index("by_account_id", ["accountId"])
|
|
55
55
|
.index("by_status", ["status"])
|
|
56
56
|
.index("by_account_id_and_status", ["accountId", "status"])
|
|
57
|
+
.index("by_account_id_and_stripe_payment_intent_id", [
|
|
58
|
+
"accountId",
|
|
59
|
+
"stripePaymentIntentId",
|
|
60
|
+
])
|
|
61
|
+
.index("by_account_id_and_stripe_payment_intent_id_and_status", [
|
|
62
|
+
"accountId",
|
|
63
|
+
"stripePaymentIntentId",
|
|
64
|
+
"status",
|
|
65
|
+
])
|
|
57
66
|
.index("by_payment_intent_id_and_status", [
|
|
58
67
|
"stripePaymentIntentId",
|
|
59
68
|
"status",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const paginate = api.refundHistory.queries.paginateRefunds;
|
|
9
|
+
afterEach(() => vi.useRealTimers());
|
|
10
|
+
const rows = [
|
|
11
|
+
{
|
|
12
|
+
stripeRefundId: "re_a1",
|
|
13
|
+
accountId: "acct_a",
|
|
14
|
+
stripePaymentIntentId: "pi_a",
|
|
15
|
+
status: "succeeded",
|
|
16
|
+
currency: "jpy",
|
|
17
|
+
amount: 500,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
stripeRefundId: "re_a2",
|
|
21
|
+
accountId: "acct_a",
|
|
22
|
+
stripePaymentIntentId: "pi_a",
|
|
23
|
+
status: "pending",
|
|
24
|
+
currency: "isk",
|
|
25
|
+
amount: 500,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
stripeRefundId: "re_a3",
|
|
29
|
+
accountId: "acct_a",
|
|
30
|
+
stripePaymentIntentId: "pi_b",
|
|
31
|
+
status: "succeeded",
|
|
32
|
+
currency: "usd",
|
|
33
|
+
amount: 100,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
stripeRefundId: "re_b1",
|
|
37
|
+
accountId: "acct_b",
|
|
38
|
+
stripePaymentIntentId: "pi_a",
|
|
39
|
+
status: "succeeded",
|
|
40
|
+
currency: "eur",
|
|
41
|
+
amount: 200,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
stripeRefundId: "re_b2",
|
|
45
|
+
accountId: "acct_b",
|
|
46
|
+
stripePaymentIntentId: "pi_only_b",
|
|
47
|
+
status: "failed",
|
|
48
|
+
currency: "usd",
|
|
49
|
+
amount: 300,
|
|
50
|
+
},
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
async function fixture() {
|
|
54
|
+
const t = convexTest(schema, modules);
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
for (const [index, row] of rows.entries()) {
|
|
57
|
+
vi.setSystemTime(1800000000000 + index * 1000);
|
|
58
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
59
|
+
...row,
|
|
60
|
+
stripeChargeId: `ch_${index}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("walks every original refund over bounded newest-first pages and replays a cursor", async () => {
|
|
67
|
+
const t = await fixture();
|
|
68
|
+
const first = await t.query(paginate, {
|
|
69
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
70
|
+
});
|
|
71
|
+
expect(
|
|
72
|
+
first.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
73
|
+
).toEqual(["re_b2", "re_b1"]);
|
|
74
|
+
expect(first.isDone).toBe(false);
|
|
75
|
+
const args = {
|
|
76
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
77
|
+
};
|
|
78
|
+
const second = await t.query(paginate, args);
|
|
79
|
+
expect(await t.query(paginate, args)).toEqual(second);
|
|
80
|
+
const last = await t.query(paginate, {
|
|
81
|
+
paginationOpts: { numItems: 2, cursor: second.continueCursor },
|
|
82
|
+
});
|
|
83
|
+
expect(last.isDone).toBe(true);
|
|
84
|
+
expect(
|
|
85
|
+
[...first.page, ...second.page, ...last.page].map((row) => ({
|
|
86
|
+
stripeRefundId: row.stripeRefundId,
|
|
87
|
+
accountId: row.accountId,
|
|
88
|
+
stripePaymentIntentId: row.stripePaymentIntentId,
|
|
89
|
+
status: row.status,
|
|
90
|
+
currency: row.currency,
|
|
91
|
+
amount: row.amount,
|
|
92
|
+
})),
|
|
93
|
+
).toEqual([...rows].reverse());
|
|
94
|
+
expect(last.page[0].stripeChargeId).toBe("ch_0");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
[{ accountId: "acct_a" }, ["re_a3", "re_a2", "re_a1"]],
|
|
99
|
+
[{ stripePaymentIntentId: "pi_a" }, ["re_b1", "re_a2", "re_a1"]],
|
|
100
|
+
[{ status: "succeeded" }, ["re_b1", "re_a3", "re_a1"]],
|
|
101
|
+
[{ accountId: "acct_a", status: "succeeded" }, ["re_a3", "re_a1"]],
|
|
102
|
+
[{ stripePaymentIntentId: "pi_a", status: "succeeded" }, ["re_b1", "re_a1"]],
|
|
103
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_a" }, ["re_a2", "re_a1"]],
|
|
104
|
+
[
|
|
105
|
+
{ accountId: "acct_a", stripePaymentIntentId: "pi_a", status: "succeeded" },
|
|
106
|
+
["re_a1"],
|
|
107
|
+
],
|
|
108
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_only_b" }, []],
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
accountId: "acct_a",
|
|
112
|
+
stripePaymentIntentId: "pi_only_b",
|
|
113
|
+
status: "failed",
|
|
114
|
+
},
|
|
115
|
+
[],
|
|
116
|
+
],
|
|
117
|
+
] as const)(
|
|
118
|
+
"applies every supplied scope conjunctively: %j",
|
|
119
|
+
async (scope, expected) => {
|
|
120
|
+
const t = await fixture();
|
|
121
|
+
const result = await t.query(paginate, {
|
|
122
|
+
...scope,
|
|
123
|
+
paginationOpts: { numItems: 100, cursor: null },
|
|
124
|
+
});
|
|
125
|
+
expect(
|
|
126
|
+
result.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
127
|
+
).toEqual(expected);
|
|
128
|
+
expect(result.isDone).toBe(true);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
it.each([0, -1, 1.5, 101, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
133
|
+
"rejects invalid page size %s",
|
|
134
|
+
async (numItems) => {
|
|
135
|
+
const t = convexTest(schema, modules);
|
|
136
|
+
await expect(
|
|
137
|
+
t.query(paginate, { paginationOpts: { numItems, cursor: null } }),
|
|
138
|
+
).rejects.toThrow(/page size/i);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("returns a completed empty page on empty history", async () => {
|
|
143
|
+
const t = convexTest(schema, modules);
|
|
144
|
+
expect(
|
|
145
|
+
await t.query(paginate, { paginationOpts: { numItems: 1, cursor: null } }),
|
|
146
|
+
).toMatchObject({
|
|
147
|
+
page: [],
|
|
148
|
+
isDone: true,
|
|
149
|
+
continueCursor: expect.any(String),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("pins reactive refund ranges and splits growth before bounded read limits", async () => {
|
|
154
|
+
const t = await fixture();
|
|
155
|
+
const scope = { accountId: "acct_a" };
|
|
156
|
+
const first = await t.query(paginate, {
|
|
157
|
+
...scope,
|
|
158
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
159
|
+
});
|
|
160
|
+
const second = await t.query(paginate, {
|
|
161
|
+
...scope,
|
|
162
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
163
|
+
});
|
|
164
|
+
vi.setSystemTime(1800000010000);
|
|
165
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
166
|
+
stripeRefundId: "re_new",
|
|
167
|
+
...scope,
|
|
168
|
+
amount: 500,
|
|
169
|
+
currency: "jpy",
|
|
170
|
+
status: "pending",
|
|
171
|
+
});
|
|
172
|
+
const paginationOpts = {
|
|
173
|
+
numItems: 2,
|
|
174
|
+
cursor: null,
|
|
175
|
+
endCursor: first.continueCursor,
|
|
176
|
+
};
|
|
177
|
+
const pinned = await t.query(paginate, { ...scope, paginationOpts });
|
|
178
|
+
expect(pinned.page).toHaveLength(3);
|
|
179
|
+
expect(
|
|
180
|
+
new Set([...pinned.page, ...second.page].map((row) => row.stripeRefundId))
|
|
181
|
+
.size,
|
|
182
|
+
).toBe(4);
|
|
183
|
+
for (let index = 0; index < 205; index++) {
|
|
184
|
+
vi.setSystemTime(1800000020000 + index * 1000);
|
|
185
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
186
|
+
stripeRefundId: `re_growth_${index}`,
|
|
187
|
+
...scope,
|
|
188
|
+
amount: 500,
|
|
189
|
+
currency: "jpy",
|
|
190
|
+
status: "succeeded",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const grown = await t.query(paginate, { ...scope, paginationOpts });
|
|
194
|
+
expect(grown.page).toHaveLength(200);
|
|
195
|
+
expect(grown.pageStatus).toBe("SplitRequired");
|
|
196
|
+
expect(grown.splitCursor).toBeTruthy();
|
|
197
|
+
expect(grown.page.every((row) => row.accountId === "acct_a")).toBe(true);
|
|
198
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
paginationOptsValidator,
|
|
3
|
+
paginationResultValidator,
|
|
4
|
+
} from "convex/server";
|
|
5
|
+
import { v } from "convex/values";
|
|
6
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
7
|
+
|
|
8
|
+
import { query, type QueryCtx } from "../_generated/server.js";
|
|
9
|
+
import schema from "../schema.js";
|
|
10
|
+
import {
|
|
11
|
+
refundDocValidator,
|
|
12
|
+
refundStatusValidator,
|
|
13
|
+
type RefundStatus,
|
|
14
|
+
} from "../connect/validators.js";
|
|
15
|
+
|
|
16
|
+
/** Host-authorized scopes are conjunctive; use the package React pagination binding. */
|
|
17
|
+
export const paginateRefunds = query({
|
|
18
|
+
args: {
|
|
19
|
+
accountId: v.optional(v.string()),
|
|
20
|
+
stripePaymentIntentId: v.optional(v.string()),
|
|
21
|
+
status: v.optional(refundStatusValidator),
|
|
22
|
+
paginationOpts: paginationOptsValidator,
|
|
23
|
+
},
|
|
24
|
+
returns: paginationResultValidator(refundDocValidator),
|
|
25
|
+
handler: (ctx, args) => {
|
|
26
|
+
const { paginationOpts } = args;
|
|
27
|
+
if (
|
|
28
|
+
!Number.isSafeInteger(paginationOpts.numItems) ||
|
|
29
|
+
paginationOpts.numItems < 1 ||
|
|
30
|
+
paginationOpts.numItems > 100
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Refund history page size must be an integer from 1 to 100",
|
|
34
|
+
);
|
|
35
|
+
return scopedRefunds(ctx, args)
|
|
36
|
+
.order("desc")
|
|
37
|
+
.paginate({
|
|
38
|
+
...paginationOpts,
|
|
39
|
+
maximumRowsRead: 200,
|
|
40
|
+
maximumBytesRead: 1_000_000,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function scopedRefunds(
|
|
46
|
+
ctx: QueryCtx,
|
|
47
|
+
args: {
|
|
48
|
+
accountId?: string;
|
|
49
|
+
stripePaymentIntentId?: string;
|
|
50
|
+
status?: RefundStatus;
|
|
51
|
+
},
|
|
52
|
+
) {
|
|
53
|
+
const { accountId, stripePaymentIntentId, status } = args;
|
|
54
|
+
const refunds = paginator(ctx.db, schema).query("refunds");
|
|
55
|
+
if (
|
|
56
|
+
accountId !== undefined &&
|
|
57
|
+
stripePaymentIntentId !== undefined &&
|
|
58
|
+
status === undefined
|
|
59
|
+
)
|
|
60
|
+
return refunds.withIndex(
|
|
61
|
+
"by_account_id_and_stripe_payment_intent_id",
|
|
62
|
+
(q) =>
|
|
63
|
+
q
|
|
64
|
+
.eq("accountId", accountId)
|
|
65
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
66
|
+
);
|
|
67
|
+
if (
|
|
68
|
+
accountId !== undefined &&
|
|
69
|
+
stripePaymentIntentId !== undefined &&
|
|
70
|
+
status !== undefined
|
|
71
|
+
)
|
|
72
|
+
return refunds.withIndex(
|
|
73
|
+
"by_account_id_and_stripe_payment_intent_id_and_status",
|
|
74
|
+
(q) =>
|
|
75
|
+
q
|
|
76
|
+
.eq("accountId", accountId)
|
|
77
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId)
|
|
78
|
+
.eq("status", status),
|
|
79
|
+
);
|
|
80
|
+
if (accountId !== undefined && status !== undefined)
|
|
81
|
+
return refunds.withIndex("by_account_id_and_status", (q) =>
|
|
82
|
+
q.eq("accountId", accountId).eq("status", status),
|
|
83
|
+
);
|
|
84
|
+
if (stripePaymentIntentId !== undefined && status !== undefined)
|
|
85
|
+
return refunds.withIndex("by_payment_intent_id_and_status", (q) =>
|
|
86
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId).eq("status", status),
|
|
87
|
+
);
|
|
88
|
+
if (accountId !== undefined)
|
|
89
|
+
return refunds.withIndex("by_account_id", (q) =>
|
|
90
|
+
q.eq("accountId", accountId),
|
|
91
|
+
);
|
|
92
|
+
if (stripePaymentIntentId !== undefined)
|
|
93
|
+
return refunds.withIndex("by_stripe_payment_intent_id", (q) =>
|
|
94
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
95
|
+
);
|
|
96
|
+
if (status !== undefined)
|
|
97
|
+
return refunds.withIndex("by_status", (q) => q.eq("status", status));
|
|
98
|
+
return refunds;
|
|
99
|
+
}
|
|
@@ -39,3 +39,24 @@ it("renders chargeback economics and explicitly handles missing charge links", (
|
|
|
39
39
|
expect(screen.getByText("$80.00")).toBeTruthy();
|
|
40
40
|
expect(screen.getByText("No transfer rows found.")).toBeTruthy();
|
|
41
41
|
});
|
|
42
|
+
it("distinguishes reserved recovery from money already collected", () => {
|
|
43
|
+
render(
|
|
44
|
+
<CostRecovery
|
|
45
|
+
rows={[
|
|
46
|
+
{
|
|
47
|
+
paymentIntentId: "pi_reserved",
|
|
48
|
+
amountDue: 1000,
|
|
49
|
+
collectedAmount: 400,
|
|
50
|
+
invoicedAmount: 999,
|
|
51
|
+
reservedAmount: 300,
|
|
52
|
+
outstanding: 300,
|
|
53
|
+
currency: "usd",
|
|
54
|
+
},
|
|
55
|
+
]}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText("Collected")).toBeTruthy();
|
|
59
|
+
expect(screen.getByText("Reserved")).toBeTruthy();
|
|
60
|
+
expect(screen.queryByText("$9.99")).toBeNull();
|
|
61
|
+
expect(screen.getAllByText("$3.00")).toHaveLength(2);
|
|
62
|
+
});
|
|
@@ -10,24 +10,30 @@ export type CostRecoveryRow = {
|
|
|
10
10
|
amountDue: number;
|
|
11
11
|
collectedAmount: number;
|
|
12
12
|
invoicedAmount: number;
|
|
13
|
+
reservedAmount?: number;
|
|
13
14
|
outstanding: number;
|
|
14
15
|
currency: string;
|
|
15
16
|
};
|
|
16
17
|
export function CostRecovery(props: ResourceCollectionProps<CostRecoveryRow>) {
|
|
18
|
+
const reserved = props.rows?.some((row) => row.reservedAmount !== undefined);
|
|
17
19
|
const columns: WorkflowColumn<CostRecoveryRow>[] = [
|
|
18
20
|
{ id: "payment", label: "Payment", cell: (row) => row.paymentIntentId },
|
|
19
21
|
...(
|
|
20
22
|
[
|
|
21
23
|
["amountDue", "Total cost"],
|
|
22
|
-
["collectedAmount", "Deducted from earnings"],
|
|
23
|
-
[
|
|
24
|
+
["collectedAmount", reserved ? "Collected" : "Deducted from earnings"],
|
|
25
|
+
[
|
|
26
|
+
reserved ? "reservedAmount" : "invoicedAmount",
|
|
27
|
+
reserved ? "Reserved" : "Added to invoice",
|
|
28
|
+
],
|
|
24
29
|
["outstanding", "Outstanding"],
|
|
25
30
|
] as const
|
|
26
31
|
).map(([field, label]) => ({
|
|
27
32
|
id: field,
|
|
28
33
|
label,
|
|
29
34
|
align: "end" as const,
|
|
30
|
-
cell: (row: CostRecoveryRow) =>
|
|
35
|
+
cell: (row: CostRecoveryRow) =>
|
|
36
|
+
formatPrice(row[field] ?? row.invoicedAmount, row.currency),
|
|
31
37
|
})),
|
|
32
38
|
];
|
|
33
39
|
return (
|