@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +42 -0
  4. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  5. package/dist/client/connect/payoutSync.d.ts +32 -0
  6. package/dist/client/connect/payoutSync.d.ts.map +1 -0
  7. package/dist/client/connect/payoutSync.js +48 -0
  8. package/dist/client/connect/payoutSync.js.map +1 -0
  9. package/dist/client/connect/payoutSyncTestHelpers.d.ts +1263 -0
  10. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
  11. package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
  12. package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
  13. package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
  14. package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
  15. package/dist/client/connect/payoutSyncValidation.js +54 -0
  16. package/dist/client/connect/payoutSyncValidation.js.map +1 -0
  17. package/dist/client/index.d.ts +26 -9
  18. package/dist/client/index.d.ts.map +1 -1
  19. package/dist/client/index.js +3 -5
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/readContracts.d.ts +8 -0
  22. package/dist/client/readContracts.d.ts.map +1 -1
  23. package/dist/client/readContracts.js +7 -0
  24. package/dist/client/readContracts.js.map +1 -1
  25. package/dist/component/_generated/component.d.ts +69 -0
  26. package/dist/component/_generated/component.d.ts.map +1 -1
  27. package/dist/component/connect/mutations.d.ts.map +1 -1
  28. package/dist/component/connect/mutations.js +4 -27
  29. package/dist/component/connect/mutations.js.map +1 -1
  30. package/dist/component/connect/payoutWrites.d.ts +21 -0
  31. package/dist/component/connect/payoutWrites.d.ts.map +1 -0
  32. package/dist/component/connect/payoutWrites.js +19 -0
  33. package/dist/component/connect/payoutWrites.js.map +1 -0
  34. package/dist/component/earnings/triggers.d.ts +44 -0
  35. package/dist/component/earnings/triggers.d.ts.map +1 -1
  36. package/dist/component/payoutSync/helpers.d.ts +44 -0
  37. package/dist/component/payoutSync/helpers.d.ts.map +1 -0
  38. package/dist/component/payoutSync/helpers.js +60 -0
  39. package/dist/component/payoutSync/helpers.js.map +1 -0
  40. package/dist/component/payoutSync/mutations.d.ts +51 -0
  41. package/dist/component/payoutSync/mutations.d.ts.map +1 -0
  42. package/dist/component/payoutSync/mutations.js +88 -0
  43. package/dist/component/payoutSync/mutations.js.map +1 -0
  44. package/dist/component/payoutSync/queries.d.ts +16 -0
  45. package/dist/component/payoutSync/queries.d.ts.map +1 -0
  46. package/dist/component/payoutSync/queries.js +11 -0
  47. package/dist/component/payoutSync/queries.js.map +1 -0
  48. package/dist/component/payoutSync/schema.d.ts +46 -0
  49. package/dist/component/payoutSync/schema.d.ts.map +1 -0
  50. package/dist/component/payoutSync/schema.js +22 -0
  51. package/dist/component/payoutSync/schema.js.map +1 -0
  52. package/dist/component/payoutSync/validators.d.ts +137 -0
  53. package/dist/component/payoutSync/validators.d.ts.map +1 -0
  54. package/dist/component/payoutSync/validators.js +19 -0
  55. package/dist/component/payoutSync/validators.js.map +1 -0
  56. package/dist/component/schema.d.ts +21 -0
  57. package/dist/component/schema.js +2 -0
  58. package/dist/component/schema.js.map +1 -1
  59. package/docs/read-contracts.md +40 -5
  60. package/package.json +1 -1
  61. package/src/client/connect/payoutSync.test.ts +313 -0
  62. package/src/client/connect/payoutSync.ts +93 -0
  63. package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
  64. package/src/client/connect/payoutSyncValidation.ts +74 -0
  65. package/src/client/index.ts +14 -11
  66. package/src/client/readContracts.ts +12 -0
  67. package/src/component/_generated/api.ts +10 -0
  68. package/src/component/_generated/component.ts +82 -0
  69. package/src/component/connect/mutations.ts +4 -30
  70. package/src/component/connect/payoutWrites.ts +25 -0
  71. package/src/component/payout-sync.test.ts +117 -0
  72. package/src/component/payoutSync/helpers.ts +86 -0
  73. package/src/component/payoutSync/mutations.ts +102 -0
  74. package/src/component/payoutSync/queries.ts +10 -0
  75. package/src/component/payoutSync/schema.ts +25 -0
  76. package/src/component/payoutSync/validators.ts +21 -0
  77. package/src/component/schema.ts +2 -0
@@ -1,4 +1,25 @@
1
1
  declare const _default: import("convex/server").SchemaDefinition<{
2
+ payoutSyncClaims: import("convex/server").TableDefinition<import("convex/values").VObject<{
3
+ acceptedAt?: number | undefined;
4
+ acceptedFingerprint?: string | undefined;
5
+ acceptedGeneration?: number | undefined;
6
+ accountId: string;
7
+ generation: number;
8
+ startedAt: number;
9
+ status: "accepted" | "pending" | "unknown";
10
+ stripePayoutId: string;
11
+ }, {
12
+ stripePayoutId: import("convex/values").VString<string, "required">;
13
+ accountId: import("convex/values").VString<string, "required">;
14
+ generation: import("convex/values").VFloat64<number, "required">;
15
+ status: import("convex/values").VUnion<"accepted" | "pending" | "unknown", [import("convex/values").VLiteral<"pending", "required">, import("convex/values").VLiteral<"accepted", "required">, import("convex/values").VLiteral<"unknown", "required">], "required", never>;
16
+ startedAt: import("convex/values").VFloat64<number, "required">;
17
+ acceptedGeneration: import("convex/values").VFloat64<number | undefined, "optional">;
18
+ acceptedAt: import("convex/values").VFloat64<number | undefined, "optional">;
19
+ acceptedFingerprint: import("convex/values").VString<string | undefined, "optional">;
20
+ }, "required", "acceptedAt" | "acceptedFingerprint" | "acceptedGeneration" | "accountId" | "generation" | "startedAt" | "status" | "stripePayoutId">, {
21
+ by_stripe_payout_id: ["stripePayoutId", "_creationTime"];
22
+ }, {}, {}>;
2
23
  payoutFeeBalances: import("convex/server").TableDefinition<import("convex/values").VObject<{
3
24
  accountId: string;
4
25
  amount: number;
@@ -1,3 +1,4 @@
1
+ import { claimsTable as payoutSyncClaimsTable } from "./payoutSync/schema.js";
1
2
  import { feeBalancesTable as payoutFeeBalancesTable, claimsTable as payoutReleaseClaimsTable, sourcesTable as payoutSourceAllocationsTable, } from "./payoutRelease/schema.js";
2
3
  import { policiesTable, operationsTable } from "./payoutPolicy/schema.js";
3
4
  import { currencyTotalsTable } from "./earnings/schema.js";
@@ -10,6 +11,7 @@ import { accountsTable } from "./core/schema";
10
11
  import { pricesTable, productsTable } from "./products/schema";
11
12
  import { webhookEventsTable } from "./webhooks/schema";
12
13
  export default defineSchema({
14
+ payoutSyncClaims: payoutSyncClaimsTable,
13
15
  payoutFeeBalances: payoutFeeBalancesTable,
14
16
  payoutReleaseClaims: payoutReleaseClaimsTable,
15
17
  payoutSourceAllocations: payoutSourceAllocationsTable,
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,IAAI,wBAAwB,EACvC,YAAY,IAAI,4BAA4B,GAC7C,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EACL,WAAW,EACX,aAAa,EACb,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,aAAa,EACb,YAAY,EACZ,sBAAsB,EACtB,YAAY,EACZ,wBAAwB,EACxB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,eAAe,YAAY,CAAC;IAC1B,iBAAiB,EAAE,sBAAsB;IACzC,mBAAmB,EAAE,wBAAwB;IAC7C,uBAAuB,EAAE,4BAA4B;IACrD,cAAc,EAAE,aAAa;IAC7B,sBAAsB,EAAE,eAAe;IACvC,cAAc,EAAE,mBAAmB;IACnC,cAAc,EAAE,WAAW;IAC3B,gBAAgB,EAAE,aAAa;IAC/B,cAAc,EAAE,WAAW;IAC3B,oBAAoB,EAAE,iBAAiB;IACvC,qBAAqB,EAAE,gBAAgB;IACvC,eAAe,EAAE,oBAAoB;IACrC,mBAAmB,EAAE,wBAAwB;IAC7C,OAAO;IACP,QAAQ,EAAE,aAAa;IAEvB,WAAW;IACX,QAAQ,EAAE,aAAa;IACvB,MAAM,EAAE,WAAW;IAEnB,UAAU;IACV,aAAa,EAAE,kBAAkB;IACjC,gBAAgB,EAAE,qBAAqB;IACvC,QAAQ,EAAE,aAAa;IAEvB,UAAU;IACV,QAAQ,EAAE,aAAa;IACvB,OAAO,EAAE,YAAY;IACrB,iBAAiB,EAAE,sBAAsB;IACzC,OAAO,EAAE,YAAY;IACrB,QAAQ,EAAE,aAAa;IACvB,SAAS,EAAE,cAAc;IACzB,mBAAmB,EAAE,wBAAwB;IAE7C,WAAW;IACX,aAAa,EAAE,kBAAkB;CAClC,CAAC,CAAC"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/component/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,IAAI,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC9E,OAAO,EACL,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,IAAI,wBAAwB,EACvC,YAAY,IAAI,4BAA4B,GAC7C,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EACL,WAAW,EACX,aAAa,EACb,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EACL,qBAAqB,EACrB,aAAa,EACb,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,aAAa,EACb,aAAa,EACb,YAAY,EACZ,sBAAsB,EACtB,YAAY,EACZ,wBAAwB,EACxB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAEvD,eAAe,YAAY,CAAC;IAC1B,gBAAgB,EAAE,qBAAqB;IACvC,iBAAiB,EAAE,sBAAsB;IACzC,mBAAmB,EAAE,wBAAwB;IAC7C,uBAAuB,EAAE,4BAA4B;IACrD,cAAc,EAAE,aAAa;IAC7B,sBAAsB,EAAE,eAAe;IACvC,cAAc,EAAE,mBAAmB;IACnC,cAAc,EAAE,WAAW;IAC3B,gBAAgB,EAAE,aAAa;IAC/B,cAAc,EAAE,WAAW;IAC3B,oBAAoB,EAAE,iBAAiB;IACvC,qBAAqB,EAAE,gBAAgB;IACvC,eAAe,EAAE,oBAAoB;IACrC,mBAAmB,EAAE,wBAAwB;IAC7C,OAAO;IACP,QAAQ,EAAE,aAAa;IAEvB,WAAW;IACX,QAAQ,EAAE,aAAa;IACvB,MAAM,EAAE,WAAW;IAEnB,UAAU;IACV,aAAa,EAAE,kBAAkB;IACjC,gBAAgB,EAAE,qBAAqB;IACvC,QAAQ,EAAE,aAAa;IAEvB,UAAU;IACV,QAAQ,EAAE,aAAa;IACvB,OAAO,EAAE,YAAY;IACrB,iBAAiB,EAAE,sBAAsB;IACzC,OAAO,EAAE,YAAY;IACrB,QAAQ,EAAE,aAAa;IACvB,SAAS,EAAE,cAAc;IACzB,mBAAmB,EAAE,wBAAwB;IAE7C,WAAW;IACX,aAAa,EAAE,kBAAkB;CAClC,CAAC,CAAC"}
@@ -126,14 +126,49 @@ accounts and direct callers to the new methods. Their historical scans remain
126
126
  subject to Convex read limits; migrate busy accounts to the maintained summaries.
127
127
  Full referred-sale history uses `paginateTransfersByAccount`, not the preview.
128
128
 
129
+ ## Current payout observations
130
+
131
+ Call `betterStripe.syncPayoutFromStripe(ctx, { stripePayoutId, scope })` from an
132
+ authorized server action. Use `scope: { type: "connected", accountId }` for the
133
+ recipient's connected account or explicitly use `scope: { type: "platform" }`
134
+ for a platform payout. Resolve scope from trusted host configuration and recipient
135
+ ownership. Never accept an arbitrary account or platform mode from the browser.
136
+ The method reads Stripe and updates the existing component payout record; it
137
+ never creates, cancels or releases a bank payout.
138
+
139
+ The operation works for Stripe-managed automatic and manually triggered payouts.
140
+ It retrieves the current payout under that account's Stripe request scope and
141
+ validates the payout identity, original amount and currency, status, arrival date,
142
+ method and failure details. It does not require a mature balance transaction or a
143
+ manual execution claim. A current status observation does not establish funding
144
+ maturity, payout eligibility or a policy minimum.
145
+
146
+ A durable claim assigns each read a per-payout generation. Only the newest
147
+ claimed generation can commit. A delayed older response returns `superseded`
148
+ without changing the financial row. A successful result returns `accepted`, its
149
+ generation, `changed` and the stored payout. Identical observations preserve the
150
+ financial row and exact currency totals. Retrying the same committed generation
151
+ also preserves its acceptance timestamp. A later current provider observation
152
+ can legitimately change paid to failed; no terminal-status ranking is applied.
153
+
154
+ Provider errors or invalid snapshots throw and leave the last accepted financial
155
+ observation intact. The latest incomplete claim becomes unknown when that marker
156
+ can be saved. A lost mutation response may leave a pending or already accepted
157
+ claim. Retry the operation to obtain a new generation and read Stripe again.
158
+ Never substitute the webhook event body when the current provider read fails.
159
+ The claim table holds reconciliation metadata, not a second money ledger.
160
+
161
+ This method does not yet replace the existing signed webhook dispatcher. The
162
+ prepared generation must be integrated into the shared host callback transaction.
163
+ Until that wiring is adopted, legacy webhook deliveries still apply
164
+ event-body snapshots and can regress on distinct out-of-order events. Do not call
165
+ this method and then blindly apply an old event-body upsert afterward.
166
+
129
167
  ## Payout failures
130
168
 
131
169
  Component payout rows and live `RecipientPayoutRow` snapshots expose optional
132
- `failureCode` and `failureMessage`. Signed payout events write the provider's
133
- current snapshot to the existing payout identity. A later snapshot with absent
134
- or null provider failure fields clears stored details. Legacy rows remain valid.
135
- Ordinary event replay is deduplicated. This is snapshot reconciliation and does
136
- not guarantee convergence for globally out-of-order events.
170
+ `failureCode` and `failureMessage`. A later accepted snapshot with absent or null
171
+ provider failure fields clears stored details. Legacy rows remain valid.
137
172
 
138
173
  Host apps should translate known codes into safe recovery instructions and a
139
174
  settings link. Do not log or blindly render raw provider messages. Authorize
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -0,0 +1,313 @@
1
+ // @vitest-environment edge-runtime
2
+ import { expect, it, vi } from "vitest";
3
+ import type Stripe from "stripe";
4
+ import { makeFunctionReference } from "convex/server";
5
+ import type { RunCtx } from "../helpers.js";
6
+ import {
7
+ component,
8
+ setup,
9
+ operation,
10
+ type Scope,
11
+ } from "./payoutSyncTestHelpers.js";
12
+ const symbol = Symbol.for("toReferencePath");
13
+ it("persists the current automatic payout under the connected scope without requiring mature funding", async () => {
14
+ const s = await setup();
15
+ expect(await s.run()).toMatchObject({ status: "accepted", changed: true });
16
+ expect(s.retrieve).toHaveBeenCalledWith(
17
+ "po_one",
18
+ {},
19
+ { stripeAccount: "acct_owner" },
20
+ );
21
+ expect(await s.row()).toMatchObject({
22
+ stripePayoutId: "po_one",
23
+ accountId: "acct_owner",
24
+ amount: 500,
25
+ currency: "jpy",
26
+ status: "paid",
27
+ arrivalDate: "1970-01-01T00:03:20.000Z",
28
+ });
29
+ expect(await s.run()).toMatchObject({ status: "accepted", changed: false });
30
+ expect(
31
+ await s.t.run((ctx) => ctx.db.query("payouts").collect()),
32
+ ).toHaveLength(1);
33
+ expect(
34
+ await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
35
+ ).toMatchObject({
36
+ accountId: "acct_owner",
37
+ currency: "jpy",
38
+ paidOut: 500,
39
+ count: 1,
40
+ });
41
+ });
42
+ it("allows a later authoritative paid-to-failed transition and clears obsolete failure details", async () => {
43
+ const s = await setup();
44
+ await s.run();
45
+ s.set({
46
+ status: "failed",
47
+ failure_code: "account_closed",
48
+ failure_message: "Bank account closed",
49
+ });
50
+ await s.run();
51
+ expect(await s.row()).toMatchObject({
52
+ status: "failed",
53
+ failureCode: "account_closed",
54
+ });
55
+ expect(
56
+ await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
57
+ ).toMatchObject({ paidOut: 0, count: 1 });
58
+ s.set({ status: "pending", failure_code: null, failure_message: null });
59
+ await s.run();
60
+ expect((await s.row())?.failureCode).toBeUndefined();
61
+ expect((await s.row())?.failureMessage).toBeUndefined();
62
+ });
63
+ it("keeps platform scope explicit and ignores provider metadata as account authority", async () => {
64
+ const s = await setup({ type: "platform" });
65
+ s.set({ automatic: false });
66
+ await s.run();
67
+ expect(s.retrieve).toHaveBeenCalledWith("po_one", {}, {});
68
+ expect(await s.row()).toMatchObject({
69
+ accountId: "platform",
70
+ currency: "jpy",
71
+ });
72
+ });
73
+ function deferred<T>() {
74
+ let resolve!: (value: T) => void;
75
+ const promise = new Promise<T>((done) => {
76
+ resolve = done;
77
+ });
78
+ return { promise, resolve };
79
+ }
80
+ it("supersedes a delayed old pending read after a newer paid observation commits", async () => {
81
+ const s = await setup();
82
+ const old = { ...s.read(), status: "pending" as const };
83
+ const response = deferred<Stripe.Payout>();
84
+ s.retrieve.mockImplementationOnce(() => response.promise);
85
+ const first = s.run();
86
+ await vi.waitFor(() => expect(s.retrieve).toHaveBeenCalledTimes(1));
87
+ expect(await s.run()).toMatchObject({ status: "accepted", generation: 2 });
88
+ response.resolve(old);
89
+ expect(await first).toMatchObject({ status: "superseded", generation: 1 });
90
+ expect(await s.row()).toMatchObject({ status: "paid" });
91
+ expect(
92
+ await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
93
+ ).toMatchObject({ status: "accepted", generation: 2, acceptedGeneration: 2 });
94
+ });
95
+ it("a newer failed read holds its generation instead of accepting an older in-flight snapshot", async () => {
96
+ const s = await setup();
97
+ await s.run();
98
+ const response = deferred<Stripe.Payout>();
99
+ s.retrieve.mockImplementationOnce(() => response.promise);
100
+ const first = s.run();
101
+ await vi.waitFor(() => expect(s.retrieve).toHaveBeenCalledTimes(2));
102
+ s.retrieve.mockRejectedValueOnce(new Error("Provider unavailable"));
103
+ await expect(s.run()).rejects.toThrow("Provider unavailable");
104
+ response.resolve({ ...s.read(), status: "pending" });
105
+ expect(await first).toMatchObject({ status: "superseded" });
106
+ expect(await s.row()).toMatchObject({ status: "paid" });
107
+ expect(
108
+ await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
109
+ ).toMatchObject({ status: "unknown", generation: 3, acceptedGeneration: 1 });
110
+ expect(await s.run()).toMatchObject({
111
+ status: "accepted",
112
+ generation: 4,
113
+ changed: false,
114
+ });
115
+ });
116
+ it.each(["begin", "finish"] as const)(
117
+ "recovers a lost %s response through a fresh durable generation",
118
+ async (stage) => {
119
+ const s = await setup();
120
+ let isLost = false;
121
+ const ctx = {
122
+ ...s.ctx,
123
+ runMutation: async (ref: Record<symbol, string>, args: never) => {
124
+ const saved = await s.mutate(ref, args);
125
+ if (!isLost && ref[symbol].endsWith(`/payoutSync/mutations/${stage}`)) {
126
+ isLost = true;
127
+ throw new Error("Lost component response");
128
+ }
129
+ return saved;
130
+ },
131
+ } as unknown as RunCtx;
132
+ const sync = await operation();
133
+ await expect(
134
+ sync(s.provider, component, ctx, {
135
+ stripePayoutId: "po_one",
136
+ scope: s.scope,
137
+ }),
138
+ ).rejects.toThrow("Lost component response");
139
+ expect(
140
+ await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
141
+ ).toMatchObject({
142
+ generation: 1,
143
+ status: stage === "finish" ? "accepted" : "pending",
144
+ });
145
+ expect(await s.run()).toMatchObject({
146
+ status: "accepted",
147
+ generation: 2,
148
+ changed: stage === "begin",
149
+ });
150
+ expect(
151
+ await s.t.run((ctx) => ctx.db.query("payouts").collect()),
152
+ ).toHaveLength(1);
153
+ expect(
154
+ await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
155
+ ).toMatchObject({ paidOut: 500, count: 1 });
156
+ },
157
+ );
158
+ it("rejects a foreign existing payout before requesting provider state", async () => {
159
+ const s = await setup();
160
+ await s.t.mutation(
161
+ makeFunctionReference<"mutation">("connect/mutations:upsertPayout"),
162
+ {
163
+ stripePayoutId: "po_one",
164
+ accountId: "acct_foreign",
165
+ amount: 500,
166
+ currency: "jpy",
167
+ status: "paid",
168
+ },
169
+ );
170
+ await expect(s.run()).rejects.toThrow(
171
+ "Existing payout account does not match.",
172
+ );
173
+ expect(s.retrieve).not.toHaveBeenCalled();
174
+ expect(await s.row()).toMatchObject({ accountId: "acct_foreign" });
175
+ });
176
+ it("rejects changing a payout's durable account even if no financial row was saved", async () => {
177
+ const s = await setup();
178
+ s.retrieve.mockRejectedValueOnce(new Error("Unavailable"));
179
+ await expect(s.run()).rejects.toThrow("Unavailable");
180
+ s.retrieve.mockClear();
181
+ const sync = await operation();
182
+ await expect(
183
+ sync(s.provider, component, s.ctx, {
184
+ stripePayoutId: "po_one",
185
+ scope: { type: "connected", accountId: "acct_foreign" },
186
+ }),
187
+ ).rejects.toThrow("Payout synchronization account does not match.");
188
+ expect(s.retrieve).not.toHaveBeenCalled();
189
+ expect(await s.row()).toBeNull();
190
+ });
191
+ it.each([{ amount: 501 }, { currency: "kwd" }])(
192
+ "rejects changed original money %j without changing summaries",
193
+ async (patch) => {
194
+ const s = await setup();
195
+ await s.run();
196
+ s.set(patch);
197
+ await expect(s.run()).rejects.toThrow(
198
+ "Existing payout original money does not match.",
199
+ );
200
+ expect(await s.row()).toMatchObject({ amount: 500, currency: "jpy" });
201
+ expect(
202
+ await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
203
+ ).toMatchObject({ paidOut: 500, count: 1 });
204
+ },
205
+ );
206
+ it.each([0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN])(
207
+ "rejects invalid provider amount %s without a financial record",
208
+ async (amount) => {
209
+ const s = await setup();
210
+ s.set({ amount });
211
+ await expect(s.run()).rejects.toThrow(
212
+ "Invalid payout synchronization money or method.",
213
+ );
214
+ expect(await s.row()).toBeNull();
215
+ expect(
216
+ await s.t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
217
+ ).toMatchObject({ status: "unknown" });
218
+ },
219
+ );
220
+ it("rejects an unavailable provider response and preserves retryability", async () => {
221
+ const s = await setup();
222
+ s.retrieve.mockResolvedValueOnce(null as unknown as Stripe.Payout);
223
+ await expect(s.run()).rejects.toThrow(
224
+ "Provider payout response is unavailable.",
225
+ );
226
+ expect(await s.row()).toBeNull();
227
+ expect(await s.run()).toMatchObject({ status: "accepted", generation: 2 });
228
+ });
229
+ it("rejects a foreign provider payout ID", async () => {
230
+ const s = await setup();
231
+ s.set({ id: "po_foreign" });
232
+ await expect(s.run()).rejects.toThrow(
233
+ "Provider payout identity does not match.",
234
+ );
235
+ expect(await s.row()).toBeNull();
236
+ });
237
+ it.each([NaN, -1, 8640000000001])(
238
+ "rejects invalid arrival seconds %s",
239
+ async (arrival_date) => {
240
+ const s = await setup();
241
+ s.set({ arrival_date });
242
+ await expect(s.run()).rejects.toThrow(
243
+ "Invalid provider payout arrival date.",
244
+ );
245
+ expect(await s.row()).toBeNull();
246
+ },
247
+ );
248
+ it.each(["jpy", "kwd"])(
249
+ "preserves exact safe original %s units at the maximum integer",
250
+ async (currency) => {
251
+ const s = await setup();
252
+ s.set({ currency, amount: Number.MAX_SAFE_INTEGER });
253
+ await s.run();
254
+ expect(await s.row()).toMatchObject({
255
+ currency,
256
+ amount: Number.MAX_SAFE_INTEGER,
257
+ });
258
+ expect(
259
+ await s.t.run((ctx) => ctx.db.query("currencyTotals").unique()),
260
+ ).toMatchObject({ currency, paidOut: Number.MAX_SAFE_INTEGER });
261
+ },
262
+ );
263
+ it("does not reinterpret a connected account named platform as platform authority", async () => {
264
+ const s = await setup({ type: "connected", accountId: "platform" });
265
+ await expect(s.run()).rejects.toThrow(
266
+ "Payout synchronization scope must be explicit.",
267
+ );
268
+ expect(s.retrieve).not.toHaveBeenCalled();
269
+ expect(await s.row()).toBeNull();
270
+ });
271
+ it("deduplicates equivalent provider metadata order without a financial update", async () => {
272
+ const s = await setup();
273
+ s.set({ metadata: { b: "two", a: "one" } });
274
+ await s.run();
275
+ s.set({ metadata: { a: "one", b: "two" } });
276
+ expect(await s.run()).toMatchObject({ status: "accepted", changed: false });
277
+ });
278
+ it("publishes authoritative synchronization through the actual BetterStripe client", async () => {
279
+ const s = await setup();
280
+ const { BetterStripe } = await import("../index.js");
281
+ const spy = vi
282
+ .spyOn(
283
+ BetterStripe.prototype as unknown as { stripe: () => Stripe },
284
+ "stripe",
285
+ )
286
+ .mockReturnValue(s.provider);
287
+ try {
288
+ const client = new BetterStripe(component) as InstanceType<
289
+ typeof BetterStripe
290
+ > & {
291
+ syncPayoutFromStripe: (
292
+ ctx: RunCtx,
293
+ opts: { stripePayoutId: string; scope: Scope },
294
+ ) => Promise<unknown>;
295
+ };
296
+ expect(
297
+ client.syncPayoutFromStripe,
298
+ "public authoritative payout method is missing",
299
+ ).toBeTypeOf("function");
300
+ expect(
301
+ await client.syncPayoutFromStripe(s.ctx, {
302
+ stripePayoutId: "po_one",
303
+ scope: s.scope,
304
+ }),
305
+ ).toMatchObject({ status: "accepted" });
306
+ expect(await s.row()).toMatchObject({
307
+ accountId: "acct_owner",
308
+ status: "paid",
309
+ });
310
+ } finally {
311
+ spy.mockRestore();
312
+ }
313
+ });
@@ -0,0 +1,93 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { runMutationOrThrow } from "../helpers.js";
5
+ import { componentRef } from "../webhooks/helpers.js";
6
+ import { validateIdentity } from "../../component/payoutSync/helpers.js";
7
+ import type { result } from "../../component/payoutSync/validators.js";
8
+ import {
9
+ payoutAccount,
10
+ payoutSnapshot,
11
+ type PayoutSyncScope,
12
+ } from "./payoutSyncValidation.js";
13
+ export type PayoutSyncOptions = {
14
+ stripePayoutId: string;
15
+ scope: PayoutSyncScope;
16
+ };
17
+ export type PayoutSyncResult = Infer<typeof result>;
18
+ export type PreparedPayoutSync = Awaited<ReturnType<typeof preparePayoutSync>>;
19
+ async function markUnknown(
20
+ component: Component,
21
+ ctx: RunCtx,
22
+ observation: {
23
+ stripePayoutId: string;
24
+ accountId: string;
25
+ generation: number;
26
+ },
27
+ ) {
28
+ await runMutationOrThrow(
29
+ ctx,
30
+ componentRef(component, "payoutSync/mutations/markUnknown"),
31
+ observation,
32
+ );
33
+ }
34
+ /** Package-internal preparation for transactional host webhook dispatch. No financial record write. */
35
+ export async function preparePayoutSync(
36
+ stripe: Stripe,
37
+ component: Component,
38
+ ctx: RunCtx,
39
+ opts: PayoutSyncOptions,
40
+ ) {
41
+ const identity = {
42
+ stripePayoutId: opts.stripePayoutId,
43
+ accountId: payoutAccount(opts.scope),
44
+ };
45
+ validateIdentity(identity);
46
+ if (!ctx.runMutation)
47
+ throw new Error("Payout synchronization requires runMutation.");
48
+ const generation = (await runMutationOrThrow(
49
+ ctx,
50
+ componentRef(component, "payoutSync/mutations/begin"),
51
+ identity,
52
+ )) as number;
53
+ try {
54
+ const payout = await stripe.payouts.retrieve(
55
+ identity.stripePayoutId,
56
+ {},
57
+ identity.accountId === "platform"
58
+ ? {}
59
+ : { stripeAccount: identity.accountId },
60
+ );
61
+ return {
62
+ ...identity,
63
+ generation,
64
+ payout: payoutSnapshot(payout, identity),
65
+ };
66
+ } catch (error) {
67
+ await markUnknown(component, ctx, { ...identity, generation });
68
+ throw error;
69
+ }
70
+ }
71
+ /** Host must authorize scope. This only reads Stripe and synchronizes the existing financial record. */
72
+ export async function syncPayoutFromStripe(
73
+ stripe: Stripe,
74
+ component: Component,
75
+ ctx: RunCtx,
76
+ opts: PayoutSyncOptions,
77
+ ): Promise<PayoutSyncResult> {
78
+ const observation = await preparePayoutSync(stripe, component, ctx, opts);
79
+ try {
80
+ return (await runMutationOrThrow(
81
+ ctx,
82
+ componentRef(component, "payoutSync/mutations/finish"),
83
+ observation,
84
+ )) as PayoutSyncResult;
85
+ } catch (error) {
86
+ await markUnknown(component, ctx, {
87
+ stripePayoutId: observation.stripePayoutId,
88
+ accountId: observation.accountId,
89
+ generation: observation.generation,
90
+ });
91
+ throw error;
92
+ }
93
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, vi } from "vitest";
5
+ import type Stripe from "stripe";
6
+ import type { Component, RunCtx } from "../helpers.js";
7
+ import schema from "../../component/schema.js";
8
+ const modules = import.meta.glob("../../component/**/*.*s");
9
+ const symbol = Symbol.for("toReferencePath");
10
+ export const component = {
11
+ payoutPolicy: {
12
+ queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
13
+ },
14
+ } as unknown as Component;
15
+ export type Scope =
16
+ { type: "connected"; accountId: string } | { type: "platform" };
17
+ type Sync = (
18
+ stripe: Stripe,
19
+ component: Component,
20
+ ctx: RunCtx,
21
+ opts: { stripePayoutId: string; scope: Scope },
22
+ ) => Promise<unknown>;
23
+ export async function operation() {
24
+ const path = "./payoutSync.js";
25
+ const mod = (await import(path).catch(() => ({}))) as {
26
+ syncPayoutFromStripe?: Sync;
27
+ };
28
+ expect(
29
+ mod.syncPayoutFromStripe,
30
+ "authoritative payout synchronization is missing",
31
+ ).toBeTypeOf("function");
32
+ return mod.syncPayoutFromStripe!;
33
+ }
34
+ export async function setup(
35
+ scope: Scope = { type: "connected", accountId: "acct_owner" },
36
+ ) {
37
+ const t = convexTest(schema, modules);
38
+ const path = (ref: Record<symbol, string>) =>
39
+ ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
40
+ const mutate = (ref: Record<symbol, string>, args: never) =>
41
+ t.mutation(makeFunctionReference<"mutation">(path(ref)), args);
42
+ const ctx = {
43
+ runQuery: (ref: Record<symbol, string>, args: never) =>
44
+ t.query(makeFunctionReference<"query">(path(ref)), args),
45
+ runMutation: mutate,
46
+ } as unknown as RunCtx;
47
+ let payout = {
48
+ id: "po_one",
49
+ amount: 500,
50
+ currency: "jpy",
51
+ status: "paid",
52
+ arrival_date: 200,
53
+ created: 100,
54
+ automatic: true,
55
+ method: "standard",
56
+ type: "bank_account",
57
+ source_type: "card",
58
+ destination: "ba_one",
59
+ balance_transaction: "txn_pending",
60
+ failure_code: null,
61
+ failure_message: null,
62
+ metadata: { accountId: "acct_metadata_is_not_authority" },
63
+ } as unknown as Stripe.Payout;
64
+ const retrieve = vi.fn(async () => ({ ...payout }));
65
+ const provider = { payouts: { retrieve } } as unknown as Stripe;
66
+ const sync = await operation();
67
+ return {
68
+ t,
69
+ ctx,
70
+ mutate,
71
+ retrieve,
72
+ provider,
73
+ scope,
74
+ set: (patch: Partial<Stripe.Payout>) => {
75
+ payout = { ...payout, ...patch };
76
+ },
77
+ read: () => ({ ...payout }),
78
+ run: () =>
79
+ sync(provider, component, ctx, { stripePayoutId: "po_one", scope }),
80
+ row: () => t.run((ctx) => ctx.db.query("payouts").unique()),
81
+ };
82
+ }
@@ -0,0 +1,74 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { snapshot } from "../../component/payoutSync/validators.js";
4
+ import {
5
+ validateIdentity,
6
+ validateSnapshot,
7
+ } from "../../component/payoutSync/helpers.js";
8
+ export type PayoutSyncScope =
9
+ { type: "connected"; accountId: string } | { type: "platform" };
10
+ export function payoutAccount(scope: PayoutSyncScope) {
11
+ if (scope?.type === "platform" && Object.keys(scope).length === 1)
12
+ return "platform";
13
+ if (
14
+ scope?.type === "connected" &&
15
+ Object.keys(scope).length === 2 &&
16
+ typeof scope.accountId === "string" &&
17
+ /^acct_[A-Za-z0-9_]+$/.test(scope.accountId)
18
+ )
19
+ return scope.accountId;
20
+ throw new Error("Payout synchronization scope must be explicit.");
21
+ }
22
+ /** Status observations do not establish funding maturity or payout eligibility. */
23
+ export function payoutSnapshot(
24
+ p: Stripe.Payout,
25
+ args: { stripePayoutId: string; accountId: string },
26
+ ): Infer<typeof snapshot> {
27
+ validateIdentity(args);
28
+ if (!p || typeof p !== "object")
29
+ throw new Error("Provider payout response is unavailable.");
30
+ if (p.id !== args.stripePayoutId)
31
+ throw new Error("Provider payout identity does not match.");
32
+ if (
33
+ (p.status !== "pending" &&
34
+ p.status !== "paid" &&
35
+ p.status !== "failed" &&
36
+ p.status !== "canceled" &&
37
+ p.status !== "in_transit") ||
38
+ typeof p.automatic !== "boolean" ||
39
+ !["standard", "instant"].includes(p.method)
40
+ )
41
+ throw new Error("Provider payout status or method is unsupported.");
42
+ if (
43
+ !Number.isSafeInteger(p.arrival_date) ||
44
+ p.arrival_date < 0 ||
45
+ !Number.isSafeInteger(p.arrival_date * 1000) ||
46
+ p.arrival_date * 1000 > 8640000000000000
47
+ )
48
+ throw new Error("Invalid provider payout arrival date.");
49
+ if (
50
+ (p.failure_code != null && typeof p.failure_code !== "string") ||
51
+ (p.failure_message != null && typeof p.failure_message !== "string")
52
+ )
53
+ throw new Error("Invalid provider payout failure detail.");
54
+ const metadata = p.metadata ?? {};
55
+ if (
56
+ typeof metadata !== "object" ||
57
+ Array.isArray(metadata) ||
58
+ Object.values(metadata).some((v) => typeof v !== "string")
59
+ )
60
+ throw new Error("Invalid provider payout metadata.");
61
+ const result: Infer<typeof snapshot> = {
62
+ ...args,
63
+ amount: p.amount,
64
+ currency: p.currency,
65
+ status: p.status,
66
+ arrivalDate: new Date(p.arrival_date * 1000).toISOString(),
67
+ method: p.method,
68
+ failureCode: p.failure_code ?? undefined,
69
+ failureMessage: p.failure_message ?? undefined,
70
+ metadata,
71
+ };
72
+ validateSnapshot(result);
73
+ return result;
74
+ }