@dineway-ai/plugin-payment 0.2.6 → 0.2.7

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.
@@ -0,0 +1,193 @@
1
+ import { sql, type Kysely } from "kysely";
2
+
3
+ import { up as subscriptionSchema, down as removeSubscriptionSchema } from "./002_subscriptions.js";
4
+ import { schemaExists } from "./adoption.js";
5
+
6
+ export async function up(db: Kysely<unknown>): Promise<void> {
7
+ if (
8
+ await schemaExists(
9
+ db,
10
+ {
11
+ payment_subscription_purchases: [
12
+ "scope",
13
+ "id",
14
+ "user_id",
15
+ "group_key",
16
+ "contract",
17
+ "fingerprint",
18
+ "created_at",
19
+ "expires_at",
20
+ "session_id",
21
+ "subscription_id",
22
+ "closed",
23
+ "cancel_mode",
24
+ "last_attempt_at",
25
+ "last_error",
26
+ ],
27
+ payment_subscription_changes: [
28
+ "scope",
29
+ "id",
30
+ "subscription_id",
31
+ "revision",
32
+ "snapshot",
33
+ "created_at",
34
+ "acknowledged_at",
35
+ ],
36
+ },
37
+ {
38
+ payment_subscription_customers: ["subject"],
39
+ payment_member_subscriptions: ["purchase_reference", "revision", "sync_attempted_at"],
40
+ payment_subscription_periods: ["eligible", "evidence"],
41
+ payment_subscription_events: [
42
+ "digest",
43
+ "event_type",
44
+ "object_type",
45
+ "object_id",
46
+ "subscription_id",
47
+ "last_error",
48
+ "payload",
49
+ "processed_at",
50
+ "last_attempt_at",
51
+ ],
52
+ },
53
+ )
54
+ )
55
+ return;
56
+ await db.schema
57
+ .alterTable("payment_subscription_customers")
58
+ .addColumn("subject", "text")
59
+ .execute();
60
+ await sql`CREATE TABLE payment_subscription_purchases (
61
+ scope TEXT NOT NULL, id TEXT NOT NULL, user_id TEXT NOT NULL,
62
+ group_key TEXT NOT NULL, contract TEXT NOT NULL, fingerprint TEXT NOT NULL,
63
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
64
+ session_id TEXT, subscription_id TEXT, closed INTEGER NOT NULL DEFAULT 0,
65
+ cancel_mode TEXT, last_attempt_at INTEGER, last_error TEXT,
66
+ PRIMARY KEY (scope, id),
67
+ FOREIGN KEY (scope, user_id) REFERENCES payment_subscription_customers(scope, id)
68
+ )`.execute(db);
69
+ await sql`CREATE UNIQUE INDEX idx_payment_purchase_open_group ON payment_subscription_purchases(scope, user_id, group_key) WHERE closed = 0`.execute(
70
+ db,
71
+ );
72
+ await sql`CREATE UNIQUE INDEX idx_payment_purchase_session ON payment_subscription_purchases(scope, session_id) WHERE session_id IS NOT NULL`.execute(
73
+ db,
74
+ );
75
+ await sql`CREATE UNIQUE INDEX idx_payment_purchase_subscription ON payment_subscription_purchases(scope, subscription_id) WHERE subscription_id IS NOT NULL`.execute(
76
+ db,
77
+ );
78
+ await sql`CREATE INDEX idx_payment_purchase_recovery ON payment_subscription_purchases(scope, last_attempt_at, id)`.execute(
79
+ db,
80
+ );
81
+
82
+ // Dynamic prices are verified against their immutable purchase contract, not the plan catalogue.
83
+ await sql`CREATE TABLE _payment_periods_121 AS SELECT * FROM payment_subscription_periods`.execute(
84
+ db,
85
+ );
86
+ await db.schema.dropTable("payment_subscription_periods").execute();
87
+ await sql`CREATE TABLE _payment_subscriptions_121 (
88
+ scope TEXT NOT NULL, id TEXT NOT NULL, user_id TEXT NOT NULL, customer_id TEXT NOT NULL,
89
+ plan_code TEXT NOT NULL, price_id TEXT NOT NULL, status TEXT NOT NULL,
90
+ period_end INTEGER NOT NULL, cancel_at_period_end INTEGER NOT NULL,
91
+ verified_at INTEGER NOT NULL, last_error TEXT,
92
+ purchase_reference TEXT, revision INTEGER NOT NULL DEFAULT 0, sync_attempted_at INTEGER NOT NULL DEFAULT 0,
93
+ PRIMARY KEY (scope, id),
94
+ FOREIGN KEY (scope, user_id) REFERENCES payment_subscription_customers(scope, id)
95
+ )`.execute(db);
96
+ await sql`INSERT INTO _payment_subscriptions_121 (scope,id,user_id,customer_id,plan_code,price_id,status,period_end,cancel_at_period_end,verified_at,last_error)
97
+ SELECT scope,id,user_id,customer_id,plan_code,price_id,status,period_end,cancel_at_period_end,verified_at,last_error FROM payment_member_subscriptions`.execute(
98
+ db,
99
+ );
100
+ await db.schema.dropTable("payment_member_subscriptions").execute();
101
+ await db.schema
102
+ .alterTable("_payment_subscriptions_121")
103
+ .renameTo("payment_member_subscriptions")
104
+ .execute();
105
+ for (const column of [
106
+ "user_id",
107
+ "price_id",
108
+ "purchase_reference",
109
+ "verified_at",
110
+ "sync_attempted_at",
111
+ ]) {
112
+ await db.schema
113
+ .createIndex("idx_payment_subscription_" + column)
114
+ .on("payment_member_subscriptions")
115
+ .columns(["scope", column])
116
+ .execute();
117
+ }
118
+ await sql`CREATE TABLE payment_subscription_periods (
119
+ scope TEXT NOT NULL, id TEXT NOT NULL, subscription_id TEXT NOT NULL,
120
+ invoice_id TEXT NOT NULL, plan_code TEXT NOT NULL, starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL,
121
+ eligible INTEGER NOT NULL DEFAULT 0, evidence TEXT NOT NULL DEFAULT '{}',
122
+ PRIMARY KEY (scope,id), FOREIGN KEY (scope,subscription_id) REFERENCES payment_member_subscriptions(scope,id)
123
+ )`.execute(db);
124
+ await sql`INSERT INTO payment_subscription_periods (scope,id,subscription_id,invoice_id,plan_code,starts_at,ends_at)
125
+ SELECT scope,id,subscription_id,invoice_id,plan_code,starts_at,ends_at FROM _payment_periods_121`.execute(
126
+ db,
127
+ );
128
+ await db.schema.dropTable("_payment_periods_121").execute();
129
+ for (const column of ["subscription_id", "invoice_id"]) {
130
+ await db.schema
131
+ .createIndex("idx_payment_period_" + column)
132
+ .on("payment_subscription_periods")
133
+ .columns(["scope", column])
134
+ .execute();
135
+ }
136
+ for (const column of [
137
+ "digest",
138
+ "event_type",
139
+ "object_type",
140
+ "object_id",
141
+ "subscription_id",
142
+ "last_error",
143
+ "payload",
144
+ ]) {
145
+ await db.schema.alterTable("payment_subscription_events").addColumn(column, "text").execute();
146
+ }
147
+ for (const column of ["processed_at", "last_attempt_at"]) {
148
+ await db.schema
149
+ .alterTable("payment_subscription_events")
150
+ .addColumn(column, "integer")
151
+ .execute();
152
+ }
153
+ await sql`UPDATE payment_subscription_events SET processed_at = received_at`.execute(db);
154
+ await sql`CREATE INDEX idx_payment_event_pending ON payment_subscription_events(scope, processed_at, last_attempt_at)`.execute(
155
+ db,
156
+ );
157
+ await sql`CREATE TABLE payment_subscription_changes (
158
+ scope TEXT NOT NULL, id TEXT NOT NULL, subscription_id TEXT NOT NULL,
159
+ revision INTEGER NOT NULL, snapshot TEXT NOT NULL, created_at INTEGER NOT NULL, acknowledged_at INTEGER,
160
+ PRIMARY KEY (scope,id), FOREIGN KEY (scope,subscription_id) REFERENCES payment_member_subscriptions(scope,id)
161
+ )`.execute(db);
162
+ await sql`CREATE INDEX idx_payment_change_pending ON payment_subscription_changes(scope, acknowledged_at, created_at, id)`.execute(
163
+ db,
164
+ );
165
+ await sql`CREATE INDEX idx_payment_change_subscription ON payment_subscription_changes(scope, subscription_id)`.execute(
166
+ db,
167
+ );
168
+ }
169
+
170
+ export async function down(db: Kysely<unknown>): Promise<void> {
171
+ for (const table of [
172
+ "payment_subscription_changes",
173
+ "payment_subscription_purchases",
174
+ "payment_subscription_plans",
175
+ "payment_subscription_prices",
176
+ "payment_subscription_customers",
177
+ "payment_member_subscriptions",
178
+ "payment_subscription_periods",
179
+ "payment_subscription_events",
180
+ "payment_subscription_attempts",
181
+ "payment_subscription_locks",
182
+ "payment_subscription_portals",
183
+ ]) {
184
+ if ((await sql`SELECT 1 FROM ${sql.table(table)} LIMIT 1`.execute(db)).rows.length)
185
+ throw new Error(
186
+ "Restore a database snapshot to roll back populated payment tables; automatic rollback would discard payment evidence.",
187
+ );
188
+ }
189
+ await db.schema.dropTable("payment_subscription_changes").execute();
190
+ await db.schema.dropTable("payment_subscription_purchases").execute();
191
+ await removeSubscriptionSchema(db);
192
+ await subscriptionSchema(db);
193
+ }
@@ -0,0 +1,27 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ /** Adopt complete schemas installed by core; reject partial schemas without modifying their data. */
4
+ export async function schemaExists(
5
+ db: Kysely<unknown>,
6
+ owned: Record<string, readonly string[]>,
7
+ additions: Record<string, readonly string[]> = {},
8
+ ): Promise<boolean> {
9
+ const tables = await db.introspection.getTables();
10
+ const names = new Map(
11
+ tables.map((table) => [table.name, new Set(table.columns.map((column) => column.name))]),
12
+ );
13
+ const checks: boolean[] = [];
14
+ for (const [name, columns] of Object.entries(owned)) {
15
+ checks.push(names.has(name), ...columns.map((column) => names.get(name)?.has(column) ?? false));
16
+ }
17
+ for (const [name, columns] of Object.entries(additions)) {
18
+ if (!names.has(name)) throw new Error(`Payment migration requires table ${name}`);
19
+ checks.push(...columns.map((column) => names.get(name)!.has(column)));
20
+ }
21
+ if (checks.every(Boolean)) return true;
22
+ if (checks.some(Boolean))
23
+ throw new Error(
24
+ "Payment schema is incomplete; restore or repair it before retrying migrations",
25
+ );
26
+ return false;
27
+ }
@@ -0,0 +1,137 @@
1
+ import type { Database } from "dineway";
2
+ import type { Generated } from "kysely";
3
+
4
+ export interface PaymentSubscriptionKey {
5
+ scope: string;
6
+ id: string;
7
+ }
8
+ export type PaymentSubscriptionTables = {
9
+ payment_subscription_plans: PaymentSubscriptionPlanTable;
10
+ payment_subscription_prices: PaymentSubscriptionPriceTable;
11
+ payment_subscription_customers: PaymentSubscriptionCustomerTable;
12
+ payment_member_subscriptions: PaymentMemberSubscriptionTable;
13
+ payment_subscription_periods: PaymentSubscriptionPeriodTable;
14
+ payment_subscription_events: PaymentSubscriptionEventTable;
15
+ payment_subscription_attempts: PaymentSubscriptionAttemptTable;
16
+ payment_subscription_locks: PaymentSubscriptionLockTable;
17
+ payment_subscription_portals: PaymentSubscriptionPortalTable;
18
+ payment_subscription_purchases: PaymentSubscriptionPurchaseTable;
19
+ payment_subscription_changes: PaymentSubscriptionChangeTable;
20
+ };
21
+ export interface PaymentSubscriptionPlanTable extends PaymentSubscriptionKey {
22
+ name: string;
23
+ enabled: number;
24
+ entitlements: string;
25
+ version: number;
26
+ }
27
+ export interface PaymentSubscriptionPriceTable extends PaymentSubscriptionKey {
28
+ plan_code: string;
29
+ product_id: string;
30
+ currency: string;
31
+ amount: number;
32
+ interval: string;
33
+ enabled: number;
34
+ }
35
+ export interface PaymentSubscriptionCustomerTable extends PaymentSubscriptionKey {
36
+ customer_id: string;
37
+ subject: Generated<string | null>;
38
+ }
39
+ export interface PaymentMemberSubscriptionTable extends PaymentSubscriptionKey {
40
+ user_id: string;
41
+ customer_id: string;
42
+ plan_code: string;
43
+ price_id: string;
44
+ status: string;
45
+ period_end: number;
46
+ cancel_at_period_end: number;
47
+ verified_at: number;
48
+ last_error: string | null;
49
+ purchase_reference: Generated<string | null>;
50
+ revision: Generated<number>;
51
+ sync_attempted_at: Generated<number>;
52
+ }
53
+ export interface PaymentSubscriptionPeriodTable extends PaymentSubscriptionKey {
54
+ subscription_id: string;
55
+ invoice_id: string;
56
+ plan_code: string;
57
+ starts_at: number;
58
+ ends_at: number;
59
+ eligible: Generated<number>;
60
+ evidence: Generated<string>;
61
+ }
62
+ export interface PaymentSubscriptionEventTable extends PaymentSubscriptionKey {
63
+ received_at: number;
64
+ payload: Generated<string | null>;
65
+ digest: Generated<string | null>;
66
+ event_type: Generated<string | null>;
67
+ object_type: Generated<string | null>;
68
+ object_id: Generated<string | null>;
69
+ subscription_id: Generated<string | null>;
70
+ processed_at: Generated<number | null>;
71
+ last_attempt_at: Generated<number | null>;
72
+ last_error: Generated<string | null>;
73
+ }
74
+ export interface PaymentSubscriptionAttemptTable extends PaymentSubscriptionKey {
75
+ fingerprint: string;
76
+ request_id: string;
77
+ session_id: string | null;
78
+ created_at: number;
79
+ }
80
+ export interface PaymentSubscriptionLockTable extends PaymentSubscriptionKey {
81
+ token: string;
82
+ expires_at: number;
83
+ }
84
+ export interface PaymentSubscriptionPortalTable extends PaymentSubscriptionKey {
85
+ configuration_id: string;
86
+ }
87
+
88
+ export interface PaymentSubscriptionPurchaseTable extends PaymentSubscriptionKey {
89
+ user_id: string;
90
+ group_key: string;
91
+ contract: string;
92
+ fingerprint: string;
93
+ created_at: number;
94
+ expires_at: number;
95
+ session_id: Generated<string | null>;
96
+ subscription_id: Generated<string | null>;
97
+ closed: Generated<number>;
98
+ cancel_mode: Generated<string | null>;
99
+ last_attempt_at: Generated<number | null>;
100
+ last_error: Generated<string | null>;
101
+ }
102
+
103
+ export interface PaymentSubscriptionChangeTable extends PaymentSubscriptionKey {
104
+ subscription_id: string;
105
+ revision: number;
106
+ snapshot: string;
107
+ created_at: number;
108
+ acknowledged_at: Generated<number | null>;
109
+ }
110
+
111
+ export type PaymentStripeMode = "test" | "live";
112
+ export type PaymentStripeConfigurationStatus = "unconfigured" | "unverified" | "valid" | "invalid";
113
+ export type PaymentStripeTaxStatus = "unknown" | "active" | "pending" | "unavailable";
114
+ export type PaymentStripeConnectStatus = "not_configured" | "restricted" | "ready";
115
+
116
+ export interface PaymentStripeSettingsTable {
117
+ id: string;
118
+ default_key: Generated<"default">;
119
+ mode: Generated<PaymentStripeMode>;
120
+ publishable_key: string | null;
121
+ configuration_status: Generated<PaymentStripeConfigurationStatus>;
122
+ stripe_account_id: string | null;
123
+ charges_enabled: number | null;
124
+ details_submitted: number | null;
125
+ tax_status: Generated<PaymentStripeTaxStatus>;
126
+ validation_error_code: string | null;
127
+ last_validated_at: string | null;
128
+ connect_status: Generated<PaymentStripeConnectStatus>;
129
+ version: Generated<number>;
130
+ created_at: Generated<string>;
131
+ updated_at: Generated<string>;
132
+ }
133
+
134
+ export type PaymentTables = PaymentSubscriptionTables & {
135
+ payment_stripe_settings: PaymentStripeSettingsTable;
136
+ };
137
+ export type PaymentDatabase = Database & PaymentTables;
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { PluginDescriptor, PluginHooks, ResolvedPlugin } from "dineway";
4
4
  import { version as packageVersion } from "../package.json";
5
5
  import { PaymentStripeConfigurationRepository } from "./configuration/repository.js";
6
6
  import { PAYMENT_PLUGIN_ID } from "./constants.js";
7
+ import { paymentMigrations } from "./database/index.js";
7
8
  import { createPaymentRoutes } from "./routes/index.js";
8
9
  import { createSubscriptionRoutes } from "./subscriptions/routes.js";
9
10
  import type { SubscriptionOptions } from "./subscriptions/types.js";
@@ -61,6 +62,7 @@ export function createPlugin(options: PaymentPluginOptions = {}): ResolvedPlugin
61
62
  : []),
62
63
  ],
63
64
  },
65
+ migrations: paymentMigrations,
64
66
  hooks,
65
67
  routes: {
66
68
  ...createPaymentRoutes(),
@@ -0,0 +1,256 @@
1
+ import type Stripe from "stripe";
2
+
3
+ import { relationId } from "./purchases.js";
4
+ import {
5
+ SubscriptionError,
6
+ type ChargeEvidence,
7
+ type InvoiceFundingEvidence,
8
+ type PurchaseContract,
9
+ type SubscriptionScope,
10
+ } from "./types.js";
11
+
12
+ /** Fail instead of silently truncating provider evidence. */
13
+ export async function collectBounded<T>(items: AsyncIterable<T>, limit = 1000): Promise<T[]> {
14
+ const result: T[] = [];
15
+ for await (const item of items) {
16
+ if (result.length === limit)
17
+ throw new SubscriptionError(
18
+ "RECONCILIATION_LIMIT",
19
+ "Payment evidence exceeds its safe limit",
20
+ 503,
21
+ );
22
+ result.push(item);
23
+ }
24
+ return result;
25
+ }
26
+
27
+ export async function inspectCharge(
28
+ stripe: Stripe,
29
+ scope: SubscriptionScope,
30
+ chargeId: string,
31
+ ): Promise<ChargeEvidence> {
32
+ const charge = await stripe.charges.retrieve(chargeId);
33
+ if (charge.livemode !== (scope.mode === "live"))
34
+ throw new SubscriptionError("MODE_MISMATCH", "Invalid charge mode");
35
+ const refunds = await collectBounded(stripe.refunds.list({ charge: charge.id, limit: 100 }));
36
+ const disputes = await collectBounded(stripe.disputes.list({ charge: charge.id, limit: 100 }));
37
+ if (
38
+ refunds.some(
39
+ (refund) => relationId(refund.charge) !== charge.id || refund.currency !== charge.currency,
40
+ ) ||
41
+ disputes.some(
42
+ (dispute) =>
43
+ relationId(dispute.charge) !== charge.id ||
44
+ dispute.currency !== charge.currency ||
45
+ dispute.livemode !== charge.livemode,
46
+ )
47
+ )
48
+ throw new SubscriptionError(
49
+ "INVALID_EVIDENCE",
50
+ "Payment evidence does not match the charge",
51
+ 409,
52
+ );
53
+ return {
54
+ id: charge.id,
55
+ paymentIntentId: relationId(charge.payment_intent),
56
+ customerId: relationId(charge.customer),
57
+ currency: charge.currency,
58
+ amount: charge.amount,
59
+ captured: charge.amount_captured,
60
+ refunded: charge.amount_refunded,
61
+ settled:
62
+ charge.paid &&
63
+ charge.captured &&
64
+ charge.status === "succeeded" &&
65
+ charge.amount_captured === charge.amount &&
66
+ Number.isSafeInteger(charge.amount) &&
67
+ charge.amount > 0 &&
68
+ Number.isSafeInteger(charge.amount_refunded) &&
69
+ charge.amount_refunded >= 0 &&
70
+ charge.amount_refunded <= charge.amount &&
71
+ (!charge.disputed ||
72
+ (disputes.length > 0 &&
73
+ disputes.every((dispute) => ["won", "warning_closed"].includes(dispute.status)))),
74
+ refunds: refunds.map((refund) => ({
75
+ id: refund.id,
76
+ amount: refund.amount,
77
+ status: refund.status ?? "unknown",
78
+ metadata: refund.metadata ?? {},
79
+ })),
80
+ disputes: disputes.map((dispute) => ({
81
+ id: dispute.id,
82
+ amount: dispute.amount,
83
+ status: dispute.status,
84
+ })),
85
+ };
86
+ }
87
+
88
+ export async function invoiceFunding(
89
+ stripe: Stripe,
90
+ scope: SubscriptionScope,
91
+ subscription: Stripe.Subscription,
92
+ invoice: Stripe.Invoice,
93
+ contract: PurchaseContract | null,
94
+ now: number,
95
+ ): Promise<InvoiceFundingEvidence> {
96
+ const item = subscription.items.data[0]!;
97
+ const amount = contract?.amount ?? item.price.unit_amount ?? 0;
98
+ const currency = contract?.currency ?? item.price.currency;
99
+ const credit =
100
+ invoice.billing_reason === "subscription_create" ? (contract?.firstPeriodCredit ?? 0) : 0;
101
+ const cash = amount - credit;
102
+ const evidence: InvoiceFundingEvidence = {
103
+ verifiedAt: now,
104
+ amount,
105
+ currency,
106
+ credit,
107
+ cash,
108
+ charges: [],
109
+ paymentMethodId: null,
110
+ reason: null,
111
+ };
112
+ const invalid = (reason: string) => ({ ...evidence, reason });
113
+ const line = invoice.lines.data[0];
114
+ const details = line?.parent?.subscription_item_details;
115
+ const customer = relationId(subscription.customer);
116
+ if (
117
+ invoice.status !== "paid" ||
118
+ invoice.livemode !== (scope.mode === "live") ||
119
+ !customer ||
120
+ relationId(invoice.customer) !== customer ||
121
+ relationId(invoice.parent?.subscription_details?.subscription) !== subscription.id ||
122
+ !["subscription_create", "subscription_cycle"].includes(invoice.billing_reason ?? "") ||
123
+ invoice.currency !== currency ||
124
+ invoice.lines.has_more ||
125
+ invoice.lines.data.length !== 1 ||
126
+ !line ||
127
+ !details ||
128
+ details.proration ||
129
+ details.subscription_item !== item.id ||
130
+ details.subscription !== subscription.id ||
131
+ relationId(line.pricing?.price_details?.price) !== item.price.id ||
132
+ line.quantity !== 1 ||
133
+ line.currency !== currency ||
134
+ line.amount !== amount ||
135
+ line.subtotal !== amount ||
136
+ invoice.subtotal !== amount ||
137
+ invoice.total !== cash ||
138
+ invoice.amount_due !== cash ||
139
+ invoice.amount_paid !== cash ||
140
+ invoice.amount_remaining !== 0 ||
141
+ invoice.starting_balance !== 0 ||
142
+ invoice.ending_balance !== 0 ||
143
+ invoice.pre_payment_credit_notes_amount !== 0 ||
144
+ invoice.post_payment_credit_notes_amount !== 0 ||
145
+ invoice.total_taxes?.some((tax) => tax.amount !== 0) ||
146
+ !Number.isSafeInteger(line.period.start) ||
147
+ !Number.isSafeInteger(line.period.end) ||
148
+ line.period.start >= line.period.end
149
+ )
150
+ return invalid("INVOICE_CONTRACT_MISMATCH");
151
+ if (
152
+ contract?.interval === "day" &&
153
+ line.period.end - line.period.start !== contract.intervalCount * 86400
154
+ )
155
+ return invalid("SERVICE_PERIOD_MISMATCH");
156
+ const discounts = invoice.total_discount_amounts ?? [];
157
+ if (credit) {
158
+ if (discounts.length !== 1 || discounts[0]!.amount !== credit)
159
+ return invalid("CREDIT_MISMATCH");
160
+ const discount = invoice.discounts.find(
161
+ (value) => typeof value !== "string" && value.id === relationId(discounts[0]!.discount),
162
+ );
163
+ if (
164
+ !discount ||
165
+ typeof discount === "string" ||
166
+ discount.source.type !== "coupon" ||
167
+ relationId(discount.source.coupon) !== contract?.couponId
168
+ )
169
+ return invalid("CREDIT_MISMATCH");
170
+ const coupon =
171
+ typeof discount.source.coupon === "string"
172
+ ? await stripe.coupons.retrieve(discount.source.coupon)
173
+ : discount.source.coupon;
174
+ if (
175
+ !coupon ||
176
+ coupon.livemode !== invoice.livemode ||
177
+ coupon.amount_off !== credit ||
178
+ coupon.currency !== currency ||
179
+ coupon.percent_off !== null ||
180
+ coupon.duration !== "once" ||
181
+ coupon.max_redemptions !== 1 ||
182
+ coupon.metadata?.dineway_app !== scope.namespace ||
183
+ coupon.metadata.purchase_reference !== contract?.reference ||
184
+ coupon.metadata.user_id !== subscription.metadata.user_id
185
+ )
186
+ return invalid("CREDIT_MISMATCH");
187
+ } else if (discounts.some((discount) => discount.amount !== 0))
188
+ return invalid("UNEXPECTED_DISCOUNT");
189
+ const payments = await collectBounded(
190
+ stripe.invoicePayments.list({
191
+ invoice: invoice.id,
192
+ status: "paid",
193
+ limit: 100,
194
+ expand: ["data.payment.payment_intent"],
195
+ }),
196
+ );
197
+ if (payments.length !== (cash ? 1 : 0)) return invalid("UNVERIFIED_CASH");
198
+ if (cash) {
199
+ const payment = payments[0]!;
200
+ const intentRef = payment.payment.payment_intent;
201
+ const intent =
202
+ typeof intentRef === "string" ? await stripe.paymentIntents.retrieve(intentRef) : intentRef;
203
+ if (
204
+ payment.status !== "paid" ||
205
+ payment.livemode !== invoice.livemode ||
206
+ payment.currency !== currency ||
207
+ relationId(payment.invoice) !== invoice.id ||
208
+ payment.amount_paid !== cash ||
209
+ payment.payment.type !== "payment_intent" ||
210
+ !intent ||
211
+ intent.status !== "succeeded" ||
212
+ intent.livemode !== invoice.livemode ||
213
+ intent.currency !== currency ||
214
+ intent.amount_received !== cash ||
215
+ relationId(intent.customer) !== customer ||
216
+ !intent.latest_charge
217
+ )
218
+ return invalid("UNVERIFIED_CASH");
219
+ const charge = await inspectCharge(stripe, scope, relationId(intent.latest_charge)!);
220
+ evidence.charges.push(charge);
221
+ if (
222
+ !charge.settled ||
223
+ charge.amount !== cash ||
224
+ charge.captured !== cash ||
225
+ charge.currency !== currency ||
226
+ charge.customerId !== customer ||
227
+ charge.paymentIntentId !== intent.id
228
+ )
229
+ return invalid("UNSETTLED_CHARGE");
230
+ if (
231
+ charge.refunded > 0 ||
232
+ charge.refunds.some((refund) => !["failed", "canceled"].includes(refund.status)) ||
233
+ charge.disputes.some((dispute) => !["won", "warning_closed"].includes(dispute.status))
234
+ )
235
+ return invalid("FUNDING_REVERSED");
236
+ } else {
237
+ let method = subscription.default_payment_method;
238
+ if (!method) {
239
+ const account = await stripe.customers.retrieve(customer);
240
+ if (account.deleted || account.livemode !== invoice.livemode)
241
+ return invalid("PAYMENT_METHOD_REQUIRED");
242
+ method = account.invoice_settings.default_payment_method;
243
+ }
244
+ if (!method) return invalid("PAYMENT_METHOD_REQUIRED");
245
+ const resolved =
246
+ typeof method === "string" ? await stripe.paymentMethods.retrieve(method) : method;
247
+ if (
248
+ resolved.type !== "card" ||
249
+ resolved.livemode !== invoice.livemode ||
250
+ relationId(resolved.customer) !== customer
251
+ )
252
+ return invalid("PAYMENT_METHOD_REQUIRED");
253
+ evidence.paymentMethodId = resolved.id;
254
+ }
255
+ return evidence;
256
+ }
@@ -4,3 +4,4 @@ export { getPaymentSubscriptionService, getSubscriptionConfiguration } from "./r
4
4
  export { createSubscriptionRoutes } from "./routes.js";
5
5
  export * from "./types.js";
6
6
  export { formatSubscriptionPrice } from "./format.js";
7
+ export type { SubscriptionRefundInput } from "./refunds.js";